Compare commits

...

39 Commits

Author SHA1 Message Date
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
132 changed files with 8970 additions and 438 deletions

View File

@@ -9,7 +9,40 @@ permissions:
contents: read
jobs:
goreleaser:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -26,35 +59,79 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -2,6 +2,83 @@
All notable changes to this project will be documented in this file.
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1608,6 +1685,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
return "", fmt.Errorf("%s: %w", flagName, err)
}
return value, nil
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

7
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.78",
"cpu": [
"x64",
"arm64"
"arm64",
"riscv64"
],
"hasInstallScript": true,
"license": "MIT",

View File

@@ -1,12 +1,13 @@
{
"name": "@larksuite/cli",
"version": "1.0.74",
"version": "1.0.78",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js",
"release:check": "node scripts/release-preflight.js"
},
"os": [
"darwin",

View File

@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error("[SECURITY] Expected checksum is missing or invalid");
}
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
throw new Error(
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
);
}
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
);
});
it("returns null when checksums.txt does not exist", () => {
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("matches case-insensitively", () => {
it("accepts a valid uppercase 64-character hex hash", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
for (const [name, expectedHash] of [
["null", null],
["empty", ""],
["non-string", 123],
]) {
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Expected checksum is missing or invalid/);
return true;
}
);
});
}
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "abc123"),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY] format Error for a non-hex hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "g".repeat(64)),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
const observed = {
packageVersion: packageVersion ?? null,
lockVersion: lockVersion ?? null,
lockRootVersion: lockRootVersion ?? null,
tagVersion: null,
};
for (const [field, value] of [
["package.json.version", packageVersion],
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
);
}
}
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
return releaseError(
"Package version fields do not match",
observed,
"Synchronize package.json.version and both package-lock.json version fields.",
);
}
if (tag === undefined) {
return { ok: true, data: observed };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
);
}
const tagVersion = tag.slice(1);
if (tagVersion !== packageVersion) {
return releaseError(
"Tag version does not match the package version",
{ ...observed, tagVersion, tag },
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
}
function writeResult(result) {
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
}
function main() {
const args = process.argv.slice(2);
let tag;
if (args.length === 2 && args[0] === "--tag") {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
return;
}
const repoRoot = path.resolve(__dirname, "..");
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
} catch (error) {
writeResult(releaseError(
"Could not read release package metadata",
{ reason: error.message },
"Ensure package.json and package-lock.json exist and contain valid JSON.",
));
}
}
module.exports = { validateReleasePreflight };
if (require.main === module) main();

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
packageJson: { version },
packageLockJson: {
version,
packages: { "": { version } },
},
};
}
function assertRejected(result) {
assert.equal(result.ok, false);
assert.equal(result.error.type, "release_preflight");
assert.equal(typeof result.error.message, "string");
}
describe("validateReleasePreflight", () => {
it("accepts matching stable package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata();
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
{
ok: true,
data: {
packageVersion: "1.2.3",
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
});
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});

View File

@@ -3,49 +3,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
VERSION=$(node -p "require('./package.json').version")
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
CURRENT_BRANCH=$(git branch --show-current)
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
exit 1
fi
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
if ! git diff --quiet HEAD -- package.json package-lock.json; then
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
exit 1
fi
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
git fetch origin main
echo "Successfully created and pushed tag ${TAG}"
HEAD_SHA=$(git rev-parse HEAD)
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
echo "Error: HEAD must exactly match origin/main before tagging." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Error: local tag ${TAG} already exists." >&2
exit 1
fi
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
if [ -n "${REMOTE_TAG}" ]; then
echo "Error: remote tag ${TAG} already exists." >&2
exit 1
fi
git tag "${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}"
echo "Successfully pushed tag ${TAG}"

View File

@@ -12,10 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,7 +14,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
return err
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -2435,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "write",
Risk: "high-risk-write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -801,7 +801,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
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`,
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
},
},
{
@@ -850,8 +851,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"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"}]`,
@@ -865,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
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",
"Happy path field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"Batch create supports max 200 records per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -2055,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2356,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2424,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2472,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2483,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2519,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2553,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
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.",
"Use only for select fields, whether multiple is false or true.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{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},
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, 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.",
"Happy path field: create_records is an array of independent record field maps.",
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"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.",
"Batch create supports max 200 records per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
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.`,
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["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.",

View File

@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -20,7 +20,6 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}
eventData := buildEventData(runtime, startTs, endTs)

View File

@@ -81,6 +81,7 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
collapseDescription(out)
return out, nil
}

View File

@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
if _, ok := body["description"]; ok {
t.Fatalf("plain description must not be sent, got: %#v", body)
}
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
}
}
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"status": "confirmed",
"description": "[测试]\n友情提醒",
"description_rich": "友情提醒",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"status": "confirmed",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
},
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
func TestAgenda_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"description": "[表格]",
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body := map[string]interface{}{}
hasFields := false
for _, field := range []string{"summary", "description"} {
if runtime.Cmd.Flags().Changed(field) {
body[field] = runtime.Str(field)
hasFields = true
}
if runtime.Cmd.Flags().Changed("summary") {
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
if description, _ := event["description"].(string); description != "" {
result["description"] = description
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"fmt"
"image"
// Register the common image decoders so DecodeConfig can read intrinsic
// dimensions for PNG/JPEG/GIF sources.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
if err != nil {
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
}
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
if len(matches) == 0 {
return md, false, nil
}
var out strings.Builder
last := 0
changed := false
cache := map[string]string{}
for _, m := range matches {
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
src := strings.TrimSpace(md[srcStart:srcEnd])
if !isLocalImageSrc(src) {
continue
}
alt := md[altStart:altEnd]
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
if err != nil {
return "", false, err
}
out.WriteString(md[last:srcStart])
out.WriteString(uploadedURL)
last = srcEnd
changed = true
}
if !changed {
return md, false, nil
}
out.WriteString(md[last:])
return out.String(), true, nil
}
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
localPath := localImagePath(src)
if cached, ok := cache[localPath]; ok {
return cached, nil
}
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
info, err := runtime.FileIO().Stat(localPath)
if err != nil {
return "", common.WrapInputStatErrorTyped(err)
}
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: localPath,
FileName: filepath.Base(safePath),
FileSize: info.Size(),
ParentType: calendarMediaParentType,
ParentNode: &calendarID,
})
if err != nil {
return "", err
}
width, height := decodeImageDimensions(runtime, localPath)
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
cache[localPath] = uploadedURL
return uploadedURL, nil
}
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return 0, 0
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0
}
return cfg.Width, cfg.Height
}
func isLocalImageSrc(src string) bool {
if src == "" {
return false
}
lower := strings.ToLower(src)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
return false
case strings.HasPrefix(lower, "file://"):
return true
}
if i := strings.Index(src, "://"); i > 0 {
return false
}
return true
}
func localImagePath(src string) string {
s := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(s), "file://") {
if u, err := url.Parse(s); err == nil && u.Path != "" {
s = u.Path
}
}
if decoded, err := url.PathUnescape(s); err == nil {
return decoded
}
return s
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.larkoffice.com"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
if width > 0 && height > 0 {
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
}
if size > 0 {
u += fmt.Sprintf("&im_size=%d", size)
}
return u
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"encoding/json"
"errors"
"image"
"image/png"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestIsLocalImageSrc(t *testing.T) {
cases := []struct {
src string
want bool
}{
{"./images/pic.png", true},
{"images/pic.png", true},
{"../assets/a.png", true},
{"/Users/me/Desktop/a.png", true},
{`C:\Users\me\a.png`, true},
{"file:///Users/me/a.png", true},
{"图片和附件/测试图片.png", true},
{"https://example.com/a.png", false},
{"http://example.com/a.png", false},
{"HTTPS://EXAMPLE.com/a.png", false},
{"data:image/png;base64,iVBOR", false},
{"ftp://host/a.png", false},
{"", false},
}
for _, c := range cases {
if got := isLocalImageSrc(c.src); got != c.want {
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestLocalImagePath(t *testing.T) {
cases := []struct{ in, want string }{
{"images/pic.png", "images/pic.png"},
{"images/my%20pic.png", "images/my pic.png"},
{"file:///Users/me/a.png", "/Users/me/a.png"},
}
for _, c := range cases {
if got := localImagePath(c.in); got != c.want {
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
// relies on: a Lark host (so token extraction triggers) whose final path
// segment is exactly the uploaded file token.
func TestBuildCalendarImagePreviewURL(t *testing.T) {
for _, tc := range []struct {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "larkoffice"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("built URL not parseable: %v", err)
}
if !strings.Contains(u.Host, tc.hostFrag) {
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
t.Errorf("last path segment = %q, want token", last)
}
q := u.Query()
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
}
}
// With unknown dimensions the helper params are omitted entirely.
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
t.Errorf("expected no dimension params for unknown size, got %q", raw)
}
}
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
// pass through unchanged and never trigger an upload (runtime unused → nil).
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
md := "text ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)"
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if changed {
t.Errorf("changed = true, want false")
}
if got != md {
t.Errorf("markdown mutated: %q", got)
}
}
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
// mocks the drive upload, and asserts the create body's description_rich carries
// the uploaded token (not the local path).
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
}
reg.Register(uploadStub)
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
if uploadStub.CapturedBody == nil {
t.Fatalf("expected drive upload to be called")
}
if createStub.CapturedBody == nil {
t.Fatalf("expected create event to be called")
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "boxcnTOKEN123") {
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
}
if strings.Contains(dr, "./pic.png") {
t.Fatalf("local path should be rewritten away, got %q", dr)
}
}
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
// intrinsic width/height and byte size are appended to the rewritten drive URL
// (so the facade can populate originalWidth/originalHeight and the client can
// render the image inline).
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
}
if !strings.Contains(dr, "im_size=") {
t.Fatalf("description_rich should carry image byte size, got %q", dr)
}
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
t.Fatalf("expected error for absolute image path")
}
var ve *errs.ValidationError
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}
func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"io"
"io/fs"
"math"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
)
// ValidateLocalFileFlag validates that a local input path exists, is a regular
// file, and does not exceed maxBytes. Absolute and relative paths use
// the process filesystem namespace.
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return err
}
info, err := cmdutil.StatLocalFile(path)
if err != nil {
return localFileReadError(param, path, "inspect", err)
}
if err := localFileRegularError(param, path, info.Mode()); err != nil {
return err
}
if info.Size() > maxBytes {
return localFileSizeError(param, path, info.Size(), maxBytes)
}
return nil
}
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
// shortcuts. It accepts absolute and relative paths, enforces a hard size
// limit, and returns command-facing typed errors.
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return nil, err
}
f, err := cmdutil.OpenLocalFile(path)
if err != nil {
return nil, localFileReadError(param, path, "open", err)
}
defer func() {
if err := f.Close(); err != nil && retErr == nil {
data = nil
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
}
}()
openedInfo, err := f.Stat()
if err != nil {
return nil, localFileReadError(param, path, "inspect opened", err)
}
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
return nil, err
}
if openedInfo.Size() > maxBytes {
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
}
readLimit := maxBytes + 1
if maxBytes == math.MaxInt64 {
readLimit = maxBytes
}
data, err = io.ReadAll(io.LimitReader(f, readLimit))
if err != nil {
return nil, localFileReadError(param, path, "read", err)
}
if int64(len(data)) > maxBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
WithParam(param)
}
return data, nil
}
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
name, param, err := localFileFlagNames(flagName)
if err != nil {
return "", "", err
}
if ctx == nil || ctx.Cmd == nil {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
}
path = strings.TrimSpace(ctx.Str(name))
if path == "" {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
}
if _, err := validate.LocalInputPath(path); err != nil {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
WithParam(param).
WithCause(err)
}
if maxBytes < 0 {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
}
return path, param, nil
}
func localFileRegularError(param, path string, mode fs.FileMode) error {
if mode.IsRegular() {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is not a regular file", param, path).
WithParam(param)
}
func localFileReadError(param, path, op string, cause error) error {
if errors.Is(cause, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
WithParam(param).
WithCause(cause)
}
if errors.Is(cause, fs.ErrNotExist) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
WithParam(param).
WithCause(cause)
}
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
}
func localFileSizeError(param, path string, size, limit int64) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
WithParam(param)
}
func localFileFlagNames(flagName string) (name, param string, err error) {
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
if name == "" {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
}
return name, "--" + name, nil
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/spf13/cobra"
)
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
rctx := localFileTestRuntime(t, path)
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
}
got, err := rctx.ReadLocalFileFlag("file", 7)
if err != nil || string(got) != "content" {
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
}
}
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
for _, tc := range []struct {
name string
path func(t *testing.T) string
max int64
}{
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
{name: "too large", path: func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "large")
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
t.Fatal(err)
}
return path
}, max: 5},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
})
}
}
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("file", "", "")
if err := cmd.Flags().Set("file", path); err != nil {
t.Fatal(err)
}
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
}

View File

@@ -3,11 +3,24 @@
package slides
import "github.com/larksuite/cli/shortcuts/common"
import (
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var presentationFlagAliases = []string{
"presentation-id",
"presentation-token",
"token",
"presentation_id",
"xml-presentation-id",
"url",
}
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
all := []common.Shortcut{
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
@@ -18,4 +31,39 @@ func Shortcuts() []common.Shortcut {
SlidesHistoryRevert,
SlidesHistoryRevertStatus,
}
for i := range all {
if hasPresentationFlag(all[i].Flags) {
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
}
}
return all
}
func hasPresentationFlag(flags []common.Flag) bool {
for _, flag := range flags {
if flag.Name == "presentation" {
return true
}
}
return false
}
// withPresentationFlagAliases accepts common agent-generated spellings for
// --presentation without registering extra flags. The aliases therefore stay
// out of help and completion while resolving to the canonical flag at parse
// time, matching the zero-round-trip compatibility used by Sheets.
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
for _, alias := range presentationFlagAliases {
if name == alias {
return pflag.NormalizedName("presentation")
}
}
return pflag.NormalizedName(name)
})
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWithPresentationFlagAliases(t *testing.T) {
for _, alias := range presentationFlagAliases {
t.Run(alias, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("presentation", "", "presentation reference")
withPresentationFlagAliases(nil)(cmd)
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Fatalf("read --presentation: %v", err)
}
if got != "presABC" {
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
}
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
}
})
}
}
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
count := 0
for _, shortcut := range Shortcuts() {
if !hasPresentationFlag(shortcut.Flags) {
continue
}
count++
if shortcut.PostMount == nil {
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
continue
}
cmd := &cobra.Command{Use: shortcut.Command}
cmd.Flags().String("presentation", "", "presentation reference")
shortcut.PostMount(cmd)
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
continue
}
got, err := cmd.Flags().GetString("presentation")
if err != nil {
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
continue
}
if got != "presABC" {
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
}
}
if count == 0 {
t.Fatal("expected at least one slides shortcut with --presentation")
}
}

View File

@@ -37,15 +37,13 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
@@ -57,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -73,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -98,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -148,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -200,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -219,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -17,23 +18,19 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
base := []string{"slides:presentation:screenshot"}
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
@@ -188,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
}
}
func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
},
{
"slide_id": "slide_2",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1,slide_2",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
}
path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
if _, err := os.ReadFile(path1); err != nil {
t.Fatalf("read first CSV slide screenshot: %v", err)
}
path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
if _, err := os.ReadFile(path2); err != nil {
t.Fatalf("read second CSV slide screenshot: %v", err)
}
}
func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
},
{
"slide_id": "slide_2",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
},
},
},
},
}
reg.Register(stub)
// CSV with a duplicate and blank segments should normalize the same way
// normalizeSlideIDs already does for repeated --slide-id flags.
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1, slide_2,slide_1,",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
}
}
func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %v, want typed validation error", err)
}
if problem.Hint != "request at most 10 pages at a time" {
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
}
}
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
@@ -390,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
}
}
func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
// Exercises the --slide-number-only side of the --content conflict check
// (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
// --slide-id side of that same `||` condition).
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--slide-number", "1",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
t.Fatalf("error = %v, want content/slide selector conflict", err)
}
}
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))

View File

@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
@@ -100,6 +101,40 @@ func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
func parseTaskGUID(input string) (string, error) {
input = strings.TrimSpace(input)
invalid := func(format string, args ...interface{}) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
WithParam("--task-id").
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
}
if input == "" {
return "", invalid("task ID is empty")
}
lowerInput := strings.ToLower(input)
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
u, err := url.Parse(input)
if err != nil {
return "", invalid("invalid task applink: %v", err).WithCause(err)
}
guid := strings.TrimSpace(u.Query().Get("guid"))
if guid == "" {
return "", invalid("task applink is missing a non-empty guid query parameter")
}
return guid, nil
}
if taskDisplayNumberPattern.MatchString(input) {
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
}
return input, nil
}
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})

View File

@@ -4,8 +4,11 @@
package task
import (
"errors"
"net/url"
"testing"
"github.com/larksuite/cli/errs"
"github.com/smartystreets/goconvey/convey"
)
@@ -15,3 +18,80 @@ func TestShortcutsRegistration(t *testing.T) {
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}
func TestParseTaskGUID(t *testing.T) {
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
{
name: "task applink",
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
want: "task-guid-123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTaskGUID(tt.input)
if err != nil {
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
})
t.Run("rejects unusable task identifiers", func(t *testing.T) {
for _, input := range []string{
"",
"https://applink.larksuite.com/client/todo/detail",
"https://%",
"t12345",
} {
t.Run(input, func(t *testing.T) {
_, err := parseTaskGUID(input)
if err == nil {
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
if problem.Hint == "" {
t.Fatal("problem hint is empty")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "--task-id" {
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
}
})
}
})
t.Run("preserves applink parse cause", func(t *testing.T) {
_, err := parseTaskGUID("https://%")
if err == nil {
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
}
var urlErr *url.Error
if !errors.As(err, &urlErr) {
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
}
})
}

View File

@@ -25,45 +25,59 @@ var CompleteTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUID(runtime.Str("task-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCompleteBody()
taskId := url.PathEscape(runtime.Str("task-id"))
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskID := url.PathEscape(taskGUID)
return common.NewDryRunAPI().
GET("/open-apis/task/v2/tasks/" + taskId).
GET("/open-apis/task/v2/tasks/" + taskID).
Desc("get current task status").
Params(map[string]interface{}{"user_id_type": "open_id"}).
PATCH("/open-apis/task/v2/tasks/" + taskId).
PATCH("/open-apis/task/v2/tasks/" + taskID).
Desc("complete task if not completed").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
if err != nil {
return err
}
taskID := url.PathEscape(taskGUID)
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
completedAtStr, _ := taskData["completed_at"].(string)
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
// 2. If already completed, directly return success
if completedAtStr != "" && completedAtStr != "0" {
if alreadyCompleted {
data = getData
} else {
// 3. Complete the task
body := buildCompleteBody()
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
if err != nil {
return err
}
@@ -73,11 +87,19 @@ var CompleteTask = common.Shortcut{
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
completedAt, _ := task["completed_at"].(string)
status := "todo"
if completedAt != "" && completedAt != "0" {
status = "done"
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
"guid": guid,
"url": urlVal,
"status": status,
"completed_at": completedAt,
"already_completed": alreadyCompleted,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {

View File

@@ -4,9 +4,12 @@
package task
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -45,6 +48,9 @@ func TestCompleteTask(t *testing.T) {
formatFlag: "json",
expectedOutput: []string{
`"guid": "task-789"`,
`"status": "done"`,
`"completed_at": "1775174400000"`,
`"already_completed": false`,
},
},
}
@@ -109,3 +115,98 @@ func TestCompleteTask(t *testing.T) {
})
}
}
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
for _, method := range []string{"GET", "PATCH"} {
reg.Register(&httpmock.Stub{
Method: method,
URL: "/open-apis/task/v2/tasks/task-guid-applink",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-applink",
"summary": "Applink task",
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
"url": "https://example.com/task-guid-applink",
},
},
},
})
}
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete",
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
}
}
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-guid-done",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-done",
"summary": "Already done",
"completed_at": "1775174400000",
"url": "https://example.com/task-guid-done",
},
},
},
})
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("CompleteTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
}
}
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, CompleteTask, []string{
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("CompleteTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}

View File

@@ -24,6 +24,14 @@ func splitAndTrimCSV(input string) []string {
return out
}
func buildSearchPageParams(pageToken string) map[string]interface{} {
params := map[string]interface{}{}
if pageToken != "" {
params["page_token"] = pageToken
}
return params
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil

View File

@@ -37,6 +37,31 @@ func TestSplitAndTrimCSV(t *testing.T) {
}
}
func TestBuildSearchPageParams(t *testing.T) {
tests := []struct {
name string
pageToken string
wantToken string
wantKey bool
}{
{name: "first page omits token"},
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := buildSearchPageParams(tt.pageToken)
got, present := params["page_token"]
if present != tt.wantKey {
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
}
if tt.wantKey && got != tt.wantToken {
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string

View File

@@ -44,8 +44,10 @@ var SearchTask = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
@@ -74,9 +76,9 @@ var SearchTask = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
params := buildSearchPageParams(runtime.Str("page-token"))
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
if err != nil {
return err
}
@@ -90,7 +92,7 @@ var SearchTask = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
params["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
@@ -183,9 +185,6 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"io"
"net/http"
"reflect"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSearchPaginationUsesQueryToken(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
command string
url string
}{
{
name: "tasks",
shortcut: SearchTask,
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
shortcut: SearchTasklist,
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
var pageTokens []string
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
shortcut := tt.shortcut
shortcut.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, shortcut, []string{
tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--page-limit", "2",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("search command failed: %v", err)
}
want := []string{"initial_pt", "next_pt"}
if !reflect.DeepEqual(pageTokens, want) {
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
}
})
}
}
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
t.Helper()
data, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal search dry-run preview: %v", err)
}
var envelope struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode search dry-run preview: %v", err)
}
if len(envelope.API) != 1 {
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
}
call := envelope.API[0]
if got, _ := call.Params["page_token"].(string); got != want {
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
}
if _, present := call.Body["page_token"]; present {
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
}
}
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
t.Helper()
return &httpmock.Stub{
Method: http.MethodPost,
URL: endpoint,
OnMatch: func(req *http.Request) {
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("read search request body: %v", err)
return
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("decode search request body: %v", err)
return
}
if _, present := payload["page_token"]; present {
t.Errorf("search request body unexpectedly contains page_token: %s", body)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": hasMore,
"page_token": responseToken,
"items": []interface{}{},
},
},
}
}

View File

@@ -37,9 +37,12 @@ func TestBuildTaskSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" || body["page_token"] != "pt_123" {
if body["query"] != "release" {
t.Fatalf("unexpected body: %#v", body)
}
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
@@ -104,9 +107,10 @@ func TestBuildTaskSearchBody(t *testing.T) {
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
}{
{
name: "valid dry run",
@@ -114,7 +118,8 @@ func TestSearchTask_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
wantPageToken: "pt_demo",
wantParts: []string{`"query":"demo"`},
},
{
name: "dry run error on invalid due",
@@ -143,7 +148,11 @@ func TestSearchTask_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTask.DryRun(nil, runtime).Format()
preview := SearchTask.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -41,8 +41,10 @@ var SearchTasklist = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
@@ -71,9 +73,9 @@ var SearchTasklist = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
params := buildSearchPageParams(runtime.Str("page-token"))
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
if err != nil {
return err
}
@@ -87,7 +89,7 @@ var SearchTasklist = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
params["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
@@ -170,9 +172,6 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
@@ -80,9 +80,10 @@ func TestBuildTasklistSearchBody(t *testing.T) {
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
}{
{
name: "valid dry run",
@@ -90,7 +91,8 @@ func TestSearchTasklist_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
wantPageToken: "pt_tl",
wantParts: []string{`"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
@@ -116,7 +118,11 @@ func TestSearchTasklist_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTasklist.DryRun(nil, runtime).Format()
preview := SearchTasklist.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -27,27 +27,42 @@ var UpdateTask = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "data", Desc: "JSON payload for task object"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseTaskGUIDs(runtime.Str("task-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
return common.NewDryRunAPI().
PATCH("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
preview := common.NewDryRunAPI()
for _, taskID := range taskIDs {
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return preview
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
if err != nil {
return err
}
body, err := buildTaskUpdateBody(runtime)
if err != nil {
// buildTaskUpdateBody already returns a typed validation error;
@@ -55,17 +70,11 @@ var UpdateTask = common.Shortcut{
return err
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
var updatedTasks []map[string]interface{}
for _, taskId := range taskIds {
taskId = strings.TrimSpace(taskId)
if taskId == "" {
continue
}
for _, taskID := range taskIDs {
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
if err != nil {
return err
}
@@ -76,19 +85,28 @@ var UpdateTask = common.Shortcut{
}
}
updateFields, _ := body["update_fields"].([]string)
var tasks []map[string]interface{}
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
confirmed := make(map[string]interface{})
for _, field := range updateFields {
if value, ok := task[field]; ok {
confirmed[field] = value
}
}
tasks = append(tasks, map[string]interface{}{
"guid": guid,
"url": urlVal,
"guid": guid,
"url": urlVal,
"confirmed": confirmed,
})
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"tasks": tasks,
"updated_fields": updateFields,
"tasks": tasks,
}
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
@@ -112,6 +130,26 @@ var UpdateTask = common.Shortcut{
},
}
func parseTaskGUIDs(input string) ([]string, error) {
parts := strings.Split(input, ",")
taskGUIDs := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
guid, err := parseTaskGUID(part)
if err != nil {
return nil, err
}
taskGUIDs = append(taskGUIDs, guid)
}
if len(taskGUIDs) == 0 {
_, err := parseTaskGUID("")
return nil, err
}
return taskGUIDs, nil
}
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
taskObj := make(map[string]interface{})
var updateFields []string

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestParseTaskGUIDs(t *testing.T) {
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
if err != nil {
t.Fatalf("parseTaskGUIDs() error = %v", err)
}
want := []string{"task-guid-1", "task-guid-2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
}
_, err = parseTaskGUIDs("task-guid-1,t12345")
if err == nil {
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
}
}
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
cmd.Flags().String("summary", "updated", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("data", "", "")
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
payload, err := json.Marshal(preview)
if err != nil {
t.Fatalf("marshal dry-run preview: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("decode dry-run preview: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
}
wantURLs := []string{
"/open-apis/task/v2/tasks/task-guid-1",
"/open-apis/task/v2/tasks/task-guid-2",
}
for i, call := range got.API {
if call.Method != "PATCH" {
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
}
if call.URL != wantURLs[i] {
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
}
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
t.Errorf("api[%d].params = %#v", i, call.Params)
}
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
}
}
}
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
first := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-1",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-1",
"url": "https://example.com/task-guid-1",
"summary": "server summary one",
"description": "server description one",
},
},
},
}
second := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/task/v2/tasks/task-guid-2",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-guid-2",
"url": "https://example.com/task-guid-2",
"summary": "server summary two",
},
},
},
}
reg.Register(first)
reg.Register(second)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
"--summary", "requested summary",
"--description", "requested description",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("UpdateTask error = %v", err)
}
reg.Verify(t)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
data, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", envelope["data"])
}
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
t.Fatalf("updated_fields = %v, want [summary description]", got)
}
tasks, ok := data["tasks"].([]interface{})
if !ok || len(tasks) != 2 {
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
}
firstTask := tasks[0].(map[string]interface{})
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
t.Fatalf("first task identifiers = %#v", firstTask)
}
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary one", "description": "server description one",
}) {
t.Fatalf("first confirmed = %#v", got)
}
secondTask := tasks[1].(map[string]interface{})
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
"summary": "server summary two",
}) {
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
}
}
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
err := runMountedTaskShortcut(t, UpdateTask, []string{
"+update",
"--task-id", "task-guid-1,t12345",
"--summary", "must not be written",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("UpdateTask error = nil, want invalid task ID error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
t.Fatalf("error param = %#v, want --task-id", validationErr)
}
}
func stringSlice(value interface{}) []string {
items, _ := value.([]interface{})
result := make([]string, 0, len(items))
for _, item := range items {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
return result
}

View File

@@ -28,7 +28,7 @@
## 各命令
### +file-list
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`
```bash
lark-cli apps +file-list --app-id app_xxx

View File

@@ -29,7 +29,7 @@ metadata:
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL简单命令由命令自身的参数、tips 和错误恢复承接
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
@@ -104,20 +104,18 @@ metadata:
## 写入前置规则
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 删除、角色更新、字段更新、表单提交(`+form-submit`等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
## 表单与视图细节
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。

View File

@@ -8,7 +8,7 @@
- `--json` 必须是 JSON 对象。
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`
- `+record-batch-create``rows``CellValue[][]`,列顺序由 `fields` 决定
- `+record-batch-create`使用 `create_records`,其每个元素都是 `Map<FieldNameOrID, CellValue>`
- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`
- 一次 payload 里同一字段只用一种 key字段名或字段 ID不要重复。
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
@@ -48,7 +48,7 @@ text 字段的 `style.type` 影响单元格检查逻辑:
### 2.3 select单选/多选)
单选用选项名字符串;多选用选项名数组。选项名建议与字段配置一致;写入未知选项时平台可能自动新增选项,因此不要把自然语言近义词当成已有选项传入
`select` 字段用 `multiple` 区分单选和多选:`multiple=false` 时传选项名字符串,`multiple=true` 时传选项名数组。只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
```json
{

View File

@@ -4,6 +4,8 @@
通过表单分享链接填写并提交多维表格表单。仅支持分享模式share_token支持填写普通字段值和上传本地文件作为附件。
> **⚠️ 高风险写操作high-risk-write** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
## 填写前必读:先获取表单详情
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
@@ -21,10 +23,11 @@ lark-cli base +form-detail --share-token <share_token>
# 2⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
# 3⃣ 再提交
# 3⃣ 再提交(高风险写操作,必须带 --yes
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}'
--json '{"fields":{...}}' \
--yes
```
`+form-detail` 的返回中要重点读取 `questions[].type``questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`
@@ -35,7 +38,8 @@ lark-cli base +form-submit \
# 基本提交(填写普通字段)
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
--yes
# 带附件提交(需要额外提供 --base-token
lark-cli base +form-submit \
@@ -47,15 +51,17 @@ lark-cli base +form-submit \
"附件字段名": ["./report.pdf", "./photo.png"],
"另一个附件字段": ["./doc.docx"]
}
}'
}' \
--yes
# 使用应用身份bot
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
--as bot
--as bot \
--yes
# 预览 API 调用(不实际执行)
# 预览 API 调用(不实际执行dry-run 无需 --yes
lark-cli base +form-submit \
--share-token <share_token> \
--json '{"fields":{...}}' \
@@ -69,6 +75,7 @@ lark-cli base +form-submit \
| `--share-token <token>` | 是 | 表单分享 Token必填从表单分享链接中提取 |
| `--base-token <token>` | 条件必填 | Base token**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write不带 `--yes` 会返回 `confirmation_required` |
| `--format` | 否 | 输出格式json默认\| pretty \| table \| ndjson \| csv |
| `--as` | 否 | 身份user默认\| bot |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
@@ -138,7 +145,8 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
```bash
lark-cli base +form-submit \
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
--json '{"fields":{...}}'
--json '{"fields":{...}}' \
--yes
```
## 输出格式
@@ -158,6 +166,7 @@ lark-cli base +form-submit \
## 提示
- **本命令为高风险写操作high-risk-write必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
- 本命令仅支持通过表单分享链接share_token提交不支持通过 base_token + table_id + view_id 方式提交
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
- 附件字段只需在 `--json.attachments` 中提供本地路径即可CLI 自动完成校验、并行上传、Token 获取和合并写入

View File

@@ -7,13 +7,13 @@
## 适用场景(重点)
- 适合导入 CSV / Excel、外部系统一次性写入新数据。
- 先把输入数据映射到合适的字段类型,再组装 `fields + rows`
- 先把每条输入数据映射为独立的字段对象,再组装 `create_records`
## 推荐命令
```bash
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> \
--json '{"fields":["标题","状态"],"rows":[["任务 A","Open"],["任务 B","Done"]]}'
--json '{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}'
lark-cli base +record-batch-create --base-token <base_token> --table-id <table_id> --json @batch-create.json
```
@@ -34,23 +34,25 @@ lark-cli base +record-batch-create --base-token <base_token> --table-id <table_i
本节只说明 `+record-batch-create` 的外层 JSON 形状CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
对象形态:`{"fields":[...],"rows":[...]}`
对象形态:
```json
{"create_records":[{"标题":"任务 A","状态":"Open"},{"标题":"任务 B","状态":"Done"}]}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `fields` | `string[]` | 是 | 字段 ID 或字段名数组 |
| `rows` | `CellValue[][]` | 是 | 二维数组,每一行按 `fields` 同序给 cell单次最多 200 行 |
| `create_records` | `Array<Map<FieldNameOrID, CellValue>>` | 是 | 记录字段对象数组;每条记录可以提交不同字段,单次最多 200 条 |
## 返回重点
返回 `fields``field_id_list``record_id_list``data`,其中 `data``fields` 列顺序对齐
返回 `record_id_list` 和可选的 `ignored_fields`
## 坑点
- `fields` 与每行 `rows` 的列顺序必须一一对应
- 空单元格必须显式用 `null` 填充
- 单次最多 200 行,超出需分批写入
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先确认真实选项名。
- 每个 `create_records` 元素都是独立的记录字段对象,只提交该记录需要写入的字段
- 单次最多 200 条,超出需分批写入
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list``+field-search-options` 确认目标选项存在
## 参考

View File

@@ -55,7 +55,7 @@ lark-cli base +record-upsert --base-token <base_token> --table-id <table_id> --r
## 坑点
-`--record-id` 就一定更新;不传就一定创建,不会自动查重或按业务键 upsert。
- select 写入未知选项时平台可能自动新增选项;如果不是要新增选项,先用 `+field-list` / `+field-search-options` 确认真实选项名
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` `+field-search-options` 确认目标选项存在
- 这是写入操作,执行前必须确认目标表和字段。
## 参考

View File

@@ -48,6 +48,8 @@ lark-cli calendar +agenda --as user
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
```
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
### `+search-event` — 按关键词、时间范围和参会人搜索日程
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`

View File

@@ -32,13 +32,14 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
| `--start <time>` | 是 | 开始时间ISO 8601`2026-03-12T14:00+08:00` |
| `--end <time>` | 是 | 结束时间ISO 8601 |
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。

View File

@@ -43,7 +43,7 @@ lark-cli calendar +update \
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;传空字符串,会把描述清空 |
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径``-`stdin读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*` |
| `--start <time>` | 否 | 新开始时间ISO 8601`2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
| `--end <time>` | 否 | 新结束时间ISO 8601。更新日程时间时必须同时传 `--start` |
| `--rrule <rrule>` | 否 | 新重复规则RFC5545。**不要使用 COUNT如需限制次数推算后转为 UNTIL** |
@@ -58,6 +58,8 @@ lark-cli calendar +update \
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
-`--summary``--description`CLI 以“是否显式传入该 flag”判断是否更新而不是以“值是否为空”判断如果显式传入空字符串会把对应字段清空。
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**``*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`
- 只想增删参会人或会议室时,不需要同时传 `--summary``--start``--end` 等日程字段。
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`

View File

@@ -41,7 +41,7 @@
### 内容限制
- HTML 总长度上限为 900000 字符。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
## OKR block

View File

@@ -13,7 +13,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
## 容器标签
|标签|说明|关键属性|
|-|-|-|
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<callout>` | 高亮框,子块仅支持文本块(如 `<p>`)、标题、列表、待办、引用;禁止裸文本及 `<table>``<img>``<pre>``<hr>``<grid>``<whiteboard>``<sheet>` 等其他块级标签或资源块 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
| `<whiteboard>` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
| `<pre>` | (代码块,内含 `code`| `lang`, `caption` |

View File

@@ -26,7 +26,10 @@ metadata:
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search``drive +move` 开始。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps``file_type=apps`),裸 token 调 shortcut 时传 `--type apps`

View File

@@ -26,11 +26,16 @@
> **`--query` 最长 30 个字符**按字符数Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
>
> **标题词 + 正文词联合搜索**:如果用户同时给出标题关键词和正文关键词,并要求同一资源同时满足两项条件,优先执行一条普通联合搜索:`lark-cli drive +search --query "标题词 正文词"`,并在同一条命令中叠加用户指定的 `--folder-tokens`、`--doc-types` 等过滤条件。不要把这种联合搜索拆成“标题搜索 + 正文搜索”后自行拼交集;也不要把 `--only-title` 或 `intitle:` 用作主候选路径。只有用户明确只查标题时,才使用 `--only-title` 或 `intitle:`。
>
> 用户要求最终返回 N 条时N 是输出上限,不等于 `--page-size N`。逐页根据 `title` 和 `summary_highlighted` 保留同时满足两项条件的候选;有效候选不足 N 且 `has_more=true` 时,保持同一 query 和过滤条件,使用 `--page-token` 继续,最多检查 3 页。摘要不足以判断正文条件时,只对标题已匹配的候选串行读取正文,确认一个再处理下一个,找到 N 条后停止;不要并发拉取正文。检查 3 页后仍不足时,返回已确认结果并建议用户调整标题词、正文词或搜索范围,不要无界扫描。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 标题含某词且正文含某词,限定文件夹内最多 N 个结果N 为最终输出上限;按上文规则分页筛选,勿作为 `--page-size` | `lark-cli drive +search --query "标题词 正文词" --folder-tokens <FOLDER_TOKEN>` |
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
@@ -96,6 +101,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
每轮扩展都要保留非污染、可解释的 evidenceURL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
扩展 query 时,优先保留用户已经指定的空间、文件夹、群聊、人员、时间和类型等 filter确需放宽检索范围时先向用户说明原因并征得确认。
## 参数
@@ -216,7 +222,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- **日历表达**"上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
- 文档中的 `"<YYYY-MM-DD>"` 是运行时占位符:执行命令前按当前日期计算并替换。例如"本月"应替换为本月第一天和下月第一天,不要把示例生成时的月份硬编码进答案
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"继续单轮翻页上限 5 页。
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。用户明确要"全部 / 全量 / 继续翻"继续;标题词 + 正文词联合搜索尚未找到足够的有效 Top N 候选时,按上文规则最多检查 3 页。其他场景单轮翻页上限 5 页。
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
## 权限

View File

@@ -0,0 +1,273 @@
# 主题资料收集工作流:执行
由状态 `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE` 加载。
本文档负责最终写操作确认、目标创建、资源移动、验证、恢复行为、`RollbackSnapshotItem` 和执行日志。不得修改搜索、召回、分类规则或计划 schema。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理写操作确认、高风险操作、身份、认证和权限。
2. 按 [`lark-drive-create-folder.md`](lark-drive-create-folder.md) 创建 Drive 文件夹。
3. 按 [`lark-drive-move.md`](lark-drive-move.md) 执行 Drive 移动。
4. 按 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) 创建 Wiki 节点。
5. 按 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 执行 Wiki 移动和 Drive 文档移动到 Wiki。
6. 按 [`../../lark-wiki/references/lark-wiki-move-to-drive.md`](../../lark-wiki/references/lark-wiki-move-to-drive.md) 将 Wiki 节点移出到 Drive 文件夹。
7. 按 [`lark-drive-delete.md`](lark-drive-delete.md) 删除本次 workflow 新建的 Drive 文件夹。
8. 按 [`../../lark-wiki/references/lark-wiki-node-delete.md`](../../lark-wiki/references/lark-wiki-node-delete.md) 删除本次 workflow 新建的 Wiki 节点。
9. 需要轮询异步任务时,按 [`lark-drive-task-result.md`](lark-drive-task-result.md) 执行。
10. `MovePlanItem` schema 由 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) 定义,本文件只消费已确认计划。
## 状态:`CONFIRM_EXECUTION`
进入条件:移动计划已准备,且用户要求执行。
必须:
1. 执行前展示所有写操作类别。
2. 将目标创建和资源移动分开展示。
3. 展示默认纳入的高相关资源。
4. 如有用户选择的中相关资源,也要展示。
5. 展示跳过分组和原因。
6. 明确展示跨容器移动。
7. 展示无移动权限和移动权限未知的资源数量。
8. 请求用户明确确认。
9. 确认前校验每个 `move_resource` 项都包含完整 `command_family``command_args`、权限快照和 `rollback_input`;缺失时必须返回 `PLAN_MOVE` 重新生成计划,不得在执行阶段补猜。
10. 只有 `move_permission_state=movable``target_write_state=confirmed` 的计划项可以列入“将移动”。
11. 对每个 `rollback_supported=false` 的计划项逐项展示标题、当前位置、目标位置、不可恢复原因和影响,不得只展示数量。
### 确认 UI
```text
请确认是否执行以下写操作:
本次搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
将创建:
- 目标名称|父级位置|目标类型
将移动:
- 标题|类型|当前位置|目标位置|原因
不会移动:
- 中相关未选择N 项
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
风险提示:
- 不可自动恢复N 项
- 标题|当前位置|目标位置|不可恢复原因|影响:移动成功后 workflow 无法自动搬回原位置,需要手动处理
- 如果搜索范围是所有当前身份可见资源,移动权限未知项不会移动。
确认后才会创建目标和移动资源。
如果不存在不可自动恢复项,请回复“确认执行”开始写操作。
如果存在不可自动恢复项,请回复“确认执行,包括不可自动恢复项”;普通“确认执行”不满足本次风险确认。
也可以回复“调整计划”返回选择资源,或回复“取消”结束流程。
```
如果用户修改选择或相关性分组,废弃当前 `move_plan_items` 并返回 `PLAN_MOVE` 重新生成计划;不得在 `CONFIRM_EXECUTION` 直接局部改写计划。
## 状态:`EXECUTE`
进入条件:用户明确确认写操作;存在 `rollback_supported=false` 的计划项时,用户已明确确认包括不可自动恢复项。
必须:
1. 只执行已确认 `MovePlanItem.command_family``command_args`;不得回查 `ResourceItem` 补齐或改写命令参数。
2. 当存在 `action_type=create_target``MovePlanItem` 时,先创建目标。
3. 目标创建后记录返回 token只允许把 `created_by_plan:<create_target plan_id>` 引用解析为该 token并把解析后的实际参数写入 `execution_journal`。不得重新搜索或猜测目标。
4. 目标 token 引用解析成功后再移动依赖该目标的资源;解析失败时停止依赖该创建目标的移动并记录 blocker不得替换为其他目标。
5. 执行任何写操作前,基于每个已确认计划项的 `rollback_input` 生成 `rollback_snapshot``rollback_supported=false` 且已有明确 `rollback_blocker` 的快照视为完整风险快照,不阻塞其他项。
6. 执行任何写操作前,初始化 `execution_journal`
7. 每次写操作尝试后记录 `execution_journal`
8. 单项失败后可继续执行相互独立的移动;目标创建失败时必须停止。
9. 不得移动 `permission_denied``no_move_permission``move_permission_unknown``unverifiable``low``unsupported_move_target` 项。
10. 不得移动 `move_permission_state!=movable``target_write_state!=confirmed` 的资源。
11. 如果移动命令返回权限错误,记录失败原因,不自动申请权限,不自动重试同一移动。
12. 如果 `rollback_supported=true``rollback_input` 缺少恢复所需字段,将该计划项标记为 `failed` / `plan_snapshot_incomplete` 并跳过;不得在未重新确认风险的情况下把它静默降级为不可恢复项,也不得阻塞其他独立项。
### 移动方式选择
| 来源 -> 目标 | 移动方式 |
|------------------|-------------|
| Drive resource -> Drive folder | `drive +move` |
| Drive document-like resource -> Wiki target | `wiki +move` 的 docs-to-wiki 模式;默认不可自动恢复 |
| Wiki node -> Wiki target | `wiki +move --node-token` |
| Wiki node -> Drive folder | `wiki +move-to-drive` |
### 执行顺序
1. 如有 `create_target` 项,先执行。
2. 按确认计划顺序执行 `move_resource` 项。
3. 如果命令返回 task ID执行异步任务轮询。
4. 输出写操作执行摘要。
### 进度 UI
批量较大时,按计数汇报进度:
```text
执行进度:已完成 <done_count>/<total_count>,成功 <success_count>,失败 <failed_count>。
当前操作:<title>
继续执行中,不需要你操作;如遇到需要确认的失败会单独提示。
```
## 状态:`VERIFY`
进入条件:执行完成。
必须:
1. 如果创建了目标,验证目标存在。
2. 能力支持时,验证已移动资源在目标位置可见。
3. 对比实际位置和 `move_plan_items`
4. 为每一项标记验证状态。
5. 只有当已有移动成功且存在严重不一致或失败时,才提供恢复选项。
6. 输出验证结果时,必须说明用户下一步可以结束流程、查看失败项,或在可恢复时选择恢复。
7. 如果出现 `async_pending`,先使用 `drive +task_result` 轮询确认;超过轮询限制后再报告 pending blocker。
### 验证结果
| 状态值 | 说明 |
|--------|------|
| `verified` | 资源已在目标位置可见。 |
| `not_found` | 目标位置未找到资源。 |
| `permission_unknown` | 当前身份无法确认结果。 |
| `async_pending` | 异步任务尚未完成,需要继续轮询。 |
| `failed` | 移动命令失败或结果不符合计划。 |
## 状态:`RESTORE`
进入条件:失败、不一致或用户明确要求恢复。
必须:
1. 只基于 `rollback_snapshot``execution_journal` 生成恢复计划。
2. 展示可恢复项和不可恢复项。
3. 执行恢复写操作前请求明确确认;确认内容必须包含反向移动和删除本次 workflow 新建目标。
4. 只恢复本次 workflow 移动过的资源。
5. 只恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
6. Drive / Wiki 跨容器移动、原父级 token 缺失等 `rollback_supported=false` 的项不得反向移动,也不得删除迁入后的文档。
7. 本次 workflow 成功创建的目标文件夹或 Wiki 节点必须纳入清理计划。
8. 删除 workflow 新建的 Wiki 目标节点时,必须使用 `wiki +node-delete --include-children=false --yes`,让已迁入的直接子文档保留到该节点父级层级。
9. 删除 workflow 新建的 Drive 文件夹前,必须先恢复或移出其中由本次 workflow 放入的资源;如果无法确认文件夹已安全可删,报告清理阻塞,不得用删除文件夹来删除用户资源。
### 恢复顺序
1. 先恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
2. 对全部 `rollback_supported=false` 的项,只记录“保留在当前目标位置,不回迁、不删除”和对应 blocker。
3. 再清理 `created_by_workflow=true` 的目标容器。
4. Wiki 新建目标清理使用 `--include-children=false`Drive 新建目标清理只在不会删除用户资源时执行。
### 恢复 UI
```text
可以尝试恢复本次已移动的资源:
可恢复:
- 标题|当前位置|原位置
不可自动恢复:
- 标题|当前位置|原位置|原因|影响:需要手动恢复
将清理本次新建目标:
- 名称|类型|清理方式
将保留在当前目标位置的跨容器迁入文档:
- 标题|当前位置|保留结果
是否执行恢复?
```
## RollbackSnapshotItem
```json
{
"snapshot_id": "稳定快照行 ID",
"plan_id": "对应 MovePlanItem.plan_id",
"resource_id": "对应 MovePlanItem.resource_id",
"source_kind": "drive|wiki",
"title": "资源标题",
"resource_type": "Drive 恢复命令需要的资源类型",
"original_token": "原始 Drive token",
"original_node_token": "原始 Wiki node token",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径",
"planned_target_parent_token": "计划目标父级 token",
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因"
}
```
| 字段 | 说明 |
|-------|------|
| `snapshot_id` | 稳定快照行 ID。 |
| `plan_id` | 对应 `MovePlanItem.plan_id`,用于连接计划、快照和执行日志。 |
| `resource_id` | 对应稳定资源 ID用于审计计划来源。 |
| `resource_type` | `drive +move` 恢复时必须传入的 `--type`;非 Drive 恢复也保留原始资源类型。 |
| `original_token` / `original_node_token` | 执行前源资源身份。 |
| `original_parent_kind` / `original_parent_token` | 执行前父级位置。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因。 |
## 执行日志
每次写操作尝试都必须追加一条内部日志:
```json
{
"journal_id": "稳定日志行 ID",
"plan_id": "对应 MovePlanItem 的 plan_id",
"time": "ISO-8601",
"action_type": "create_target|move_resource|restore_resource|cleanup_target",
"operation": "create_folder|create_node|move_drive|move_wiki_node|move_wiki_to_drive|restore_drive|restore_wiki_node|delete_folder|delete_wiki_node",
"command_family": "drive +move|wiki +move|wiki +move-to-drive|drive +create-folder|wiki +node-create|drive +delete|wiki +node-delete",
"resolved_command_args": {"<arg>": "实际发送的参数"},
"title": "资源或目标名称",
"resource_type": "资源类型",
"input_token": "命令输入 token",
"input_node_token": "命令输入 Wiki node token",
"input_parent_token": "已知源父级 token",
"target_parent_token": "目标父级 token",
"returned_token": "命令返回 token",
"returned_node_token": "命令返回 Wiki node token",
"returned_parent_token": "返回父级 token",
"task_id": "异步任务 ID",
"next_command": "异步继续命令",
"created_by_workflow": "是否由本次 workflow 创建",
"rollback_eligible": "是否可进入自动恢复计划",
"status": "success|failed|pending",
"error": "失败原因"
}
```
字段说明:
| 字段 | 说明 |
|------|------|
| `journal_id` | 稳定日志行 ID。 |
| `plan_id` | 对应 `MovePlanItem`,用于把日志项匹配回原计划。 |
| `operation` | 细分操作类型,用于区分创建、移动和恢复。 |
| `resolved_command_args` | 从确认计划解析出的实际发送参数;用于审计 `created_by_plan:<plan_id>` 的唯一运行时替换。 |
| `resource_type` | 实际移动 / 恢复使用的资源类型。 |
| `input_token` / `input_node_token` | 命令实际输入的资源 token。 |
| `input_parent_token` | 执行前已知源父级 token。 |
| `target_parent_token` | 命令输入的目标父级 token。 |
| `returned_token` / `returned_node_token` | 命令返回的资源 token恢复时作为当前源。 |
| `returned_parent_token` | 命令返回的当前父级 token。 |
| `task_id` / `next_command` | 异步任务跟踪信息。 |
| `created_by_workflow` | 是否由本次 workflow 创建,用于后续清理判断。 |
| `rollback_eligible` | 是否可进入自动恢复计划。 |
| `status` | 写操作状态,异步未完成时为 `pending`。 |
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流:召回
由状态 `SEARCH_RECALL``RECALL_ENHANCE` 加载。
本文档负责基础搜索召回、覆盖增强、query 证据、去重和 `CandidateItem`。不得解析目标移动 token、读取完整文档内容、判断相关性或执行写操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-search.md`](lark-drive-search.md) 处理 `drive +search` 语法、过滤条件、单批最多 5 页和身份语义;本 workflow 的全量续批规则见下文。
## 搜索原则
1. 默认使用 `drive +search --mine` 召回当前用户 owner / 负责的 Workspace 资源。
2. 除非用户本来就要求限定范围,否则不要要求用户指定文件夹或 Wiki 范围。
3. `SEARCH_RECALL``RECALL_ENHANCE` 必须保持为独立状态。
4. `SEARCH_RECALL` 使用用户原始关键词、`owner_scope` 和显式限制。
5. `RECALL_ENHANCE` 可以基于基础召回证据增加扩展 query且必须继承同一个 `owner_scope`
6. 每个候选项必须保留 query 证据,方便后续解释来源。
7. 单页或单个最多 5 页的 query 批次不代表完整覆盖;`has_more=true` 时必须保存 `next_page_token` 并自动开始下一批,直到 `has_more=false` 或出现阻塞。
8. 召回和增强召回可能耗时较长,执行超过 60 秒时必须输出进度提示,之后约每 60 秒提示一次。
9. 只有用户在 `CONFIRM_CONTEXT` 明确确认 `owner_scope=all_visible` 时,才允许移除 `--mine`
### 分页优先级与完成语义
1. 用户确认进入 `topic_move_collector` 即表示同意为本次收集任务执行完整召回;无需再要求用户额外说“全部 / 全量 / 继续翻”。本规则覆盖 `lark-drive-search.md` 的默认首屏交互规则。
2. 仍遵守 `lark-drive-search.md` 的单轮最多 5 页限制。每读取最多 5 页形成一个批次;批次结束且 `has_more=true` 时,保存 checkpoint并使用原 query、原过滤条件和返回的 `next_page_token` 自动开始下一批。
3. 自动续批不改变 workflow 状态,也不触发用户确认。执行超过约 60 秒时只输出进度。
4. 一个 query 只有在 `has_more=false` 时才是 `complete`。单批结束、达到 5 页或已有部分候选都不代表完成。
5. 当前状态的全部 query 都为 `complete` 后,才能进入下一状态。认证、权限、无效分页 token、连续重试失败或工具预算不足属于 blocker必须保留 checkpoint、报告部分召回并停在当前状态不得把部分结果当成完整召回继续分类。
### QueryRecallState
每个基础 / 增强 query 必须维护:
```json
{
"query_id": "稳定 query ID",
"query": "完整 query",
"recall_stage": "search_recall|recall_enhance",
"page_count": 0,
"batch_count": 0,
"next_page_token": "下一批起点",
"has_more": true,
"status": "pending|running|complete|blocked",
"blocker": "阻塞原因"
}
```
## 状态:`SEARCH_RECALL`
进入条件:用户已确认 `CONFIRM_CONTEXT`
必须:
1. 基于已确认的 `topic` 构造基础 query。
2. 应用默认 `owner_scope=mine``constraints` 中的显式限制。
3. 不隐式添加 `--folder-tokens``--space-ids`
4.`owner_scope=mine` 时,所有基础 query 必须带 `--mine`
5.`owner_scope=all_visible` 时,不带 `--mine`,并记录扩展召回风险。
6. 除非命令限制要求更低值,否则使用 `--page-size 20`
7. 每个基础 query 按每批最多 5 页执行;批次结束仍有更多结果时自动续批,并合并所有页面。
8. 记录基础统计query、搜索范围、页数、批次数、收集数量、重复数量、阻塞项。
9. 只有全部基础 query 的 `status=complete``has_more=false` 时,才进入 `RECALL_ENHANCE`;出现阻塞时保持在 `SEARCH_RECALL`
### 召回进度 UI
`SEARCH_RECALL``RECALL_ENHANCE` 持续超过约 60 秒时,输出当前进度:
```text
搜索进度:当前阶段 <SEARCH_RECALL|RECALL_ENHANCE>,已执行 <query_count> 个 query已读取 <page_count> 页,收集候选 <raw_count> 项,去重后 <unique_count> 项。继续搜索,不会创建或移动资源。
```
如果正在执行具体 query可补充
```text
当前 query<query>
```
### 基础 Query 规则
| 用户输入 | 基础 Query |
|------------|----------------|
| 单个关键词 | 直接作为 `--query`。 |
| 多个关键词组成一个短语 | 优先按用户输入的短语执行。 |
| 明确精确短语 | 保留引号。 |
| 明确排除词 | 保留负向词。 |
| 没有真实关键词,只有过滤条件 | 使用 `--query ""` 搭配过滤条件。 |
`SEARCH_RECALL` 中不得添加同义词、仅标题搜索、仅评论搜索或 OR 扩展。
### 基础召回输出
```text
基础召回完成:
- 使用 query
- 搜索范围:
- 应用限制:
- 收集候选:
- 去重后候选:
- 阻塞项:
下一步:继续执行覆盖增强,不需要你操作;不会创建或移动资源。
```
## 状态:`RECALL_ENHANCE`
进入条件:基础召回完成。
必须:
1. 基于已确认主题和基础召回证据生成增强 query。
2. 确保增强 query 可解释且不引入明显污染。
3. 每个增强 query 都必须继承 `owner_scope``owner_scope=mine` 时必须带 `--mine`
4. 每个 query 都必须按每批最多 5 页处理分页,并自动续批直到 `has_more=false`
5. 有稳定去重键时,按稳定去重键合并候选项。
6. 为每个候选项保留 `source_queries` 和命中证据。
7. 当 query 不再产生新候选,或出现工具预算 / API 阻塞时,停止增强。
### 召回阶段退出门禁
`RECALL_ENHANCE` 完成后,必须:
1. 确认全部基础和增强 query 的 `status=complete``has_more=false`,再固化完整 `candidate_items`,包含去重结果、`source_queries``match_channels``snippets``dedupe_status`
2.`current_state` 设置为 `RESOURCE_RESOLVE`
3. 加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md)。
4. 把完整 `candidate_items` 交给 `RESOURCE_RESOLVE`
5. 不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE` 或展示相关性结果。
6. 不得用搜索标题、摘要或 query 命中直接生成高 / 中 / 低相关分组。
### 增强策略
| 策略 | 说明 |
|----------|------|
| 精确短语 | 对明确短语使用 `"..."` 提高精确命中。 |
| `intitle:` | 对项目名、客户名、制度名、报表名等标题特征强的主题执行标题召回。 |
| `--only-title` | 当标题命中更可信时使用。 |
| `--only-comment` | 当主题可能只出现在评论讨论中时使用。 |
| 类型拆分 | 对 `docx``sheet``bitable``slides``file` 等分类型搜索,减少服务端排序偏差。 |
| 同义词 / 别名 | 使用业务上明确的同义词、简称、英文名、中文名。 |
| OR 扩展 | 对同一实体的别名做 OR 扩展。 |
| 负向词 | 对明显噪声使用 `-term`,但不能排除可能相关的主题词。 |
### Query 证据
每个候选项都要记录:
| 字段 | 说明 |
|-------|------|
| `source_queries` | 命中过该资源的 query 列表。 |
| `match_channels` | 命中位置,如 title、body、comment、metadata。 |
| `snippets` | 搜索返回的摘要或片段。 |
| `query_rank` | 资源在各 query 中的相对位置。 |
| `recall_stage` | `search_recall``recall_enhance`。 |
## 去重规则
必须:
1. 搜索响应提供 canonical token 时,优先使用 canonical token。
2. 对 Wiki 结果,不得只按 object token 去重;同一对象可能出现在多个 Wiki 节点中。
3. token 缺失时,使用 URL 作为 fallback。
4. 合并重复项时保留所有 query 证据。
5. 如果无法确定去重是否稳定,保留该项并设置 `dedupe_status=uncertain`
## CandidateItem
```json
{
"title": "资源标题",
"url": "资源链接",
"raw_type": "搜索返回类型",
"source_queries": ["query"],
"match_channels": ["title|body|comment|metadata"],
"snippets": ["命中片段"],
"page_rank": 1,
"dedupe_key": "候选去重键",
"dedupe_status": "stable|fallback|uncertain",
"recall_stage": "search_recall|recall_enhance"
}
```
| 字段 | 说明 |
|-------|------|
| `title` | 搜索结果标题。 |
| `url` | 资源访问链接。 |
| `raw_type` | 搜索返回的原始类型。 |
| `source_queries` | 命中过该资源的搜索 query。 |
| `match_channels` | 命中位置。 |
| `snippets` | 摘要或命中片段。 |
| `page_rank` | 当前 query 下的排序位置。 |
| `dedupe_key` | 候选去重键。 |
| `dedupe_status` | 去重可信度。 |
| `recall_stage` | 资源首次进入候选集的召回阶段。 |
## 阻塞项
缺少认证 / scope、`drive +search` 返回权限或策略阻塞、分页 token 无效、分页重试后仍无法继续,或工具预算不足以完成全部页面时,必须把对应 `QueryRecallState.status` 设置为 `blocked`,保留累计候选、页数和 `next_page_token`,停止并报告。阻塞解除后从 checkpoint 续跑;在全部 query 完成前不得进入资源解析或分类阶段。

View File

@@ -0,0 +1,231 @@
# 主题资料收集工作流:资源解析与内容验证
由状态 `RESOURCE_RESOLVE``CONTENT_VERIFY` 加载。
本文档负责资源解析、结构化父级、移动资格、内容验证和 `ResourceItem`。不得判断相关性、生成移动计划、创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-inspect.md`](lark-drive-inspect.md) 处理 URL / token 解析。
3. 使用 `drive metas batch_query` 补齐 Drive 资源 owner、标题和 URL。
4. 必要时使用 `drive permission.members auth` 读取权限信号;该接口不提供 `full_access` / 移动权限的直接判定,不能把 `manage_public` 等同为可移动。
5. 按 [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 处理 Wiki 节点解析。
6. 按 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) 读取文档内容。
7. 需要验证 Sheet 内容时,按 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md) 执行。
## 进入解析与验证阶段前校验
进入本文档后,如果 `resource_items` 还不存在,当前状态必须是 `RESOURCE_RESOLVE`
禁止从 `candidate_items` 直接进入 `CONTENT_VERIFY``RELEVANCE_CLASSIFY`,也禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使候选项已有标题、URL、摘要或 token也必须依次执行 `RESOURCE_RESOLVE``CONTENT_VERIFY`;两个状态不得合并。
## 状态:`RESOURCE_RESOLVE`
进入条件:候选列表已准备。
必须:
1. 为每个 `CandidateItem` 生成稳定 `resource_id`,并转换为标准化 `ResourceItem`
2. 解析 canonical token、资源类型、URL、结构化当前父级、Wiki 节点身份和读取权限状态。
3. 对 Wiki 资源同时保留 `wiki_node_token``wiki_obj_token`
4.`move_method` 补齐 `owner_id``is_owner``source_move_state``source_parent_write_state``target_write_state``move_permission_state``move_permission_basis`
5. 基于 `target_location` 检测不支持的移动方向。
6. 未解析成功的资源仍保留在审核分组中,不得静默丢弃。
7. 即使搜索结果已经包含标题、URL 或 token也必须经过本状态生成 `ResourceItem`;不得从召回结果直接进入相关性分级。
8. 只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源,才能进入后续默认移动链路。
9. 解析耗时超过约 60 秒时,必须输出进度提示,之后约每 60 秒提示一次。
### 解析规则
| 候选类型 | agent 必须执行 |
|----------------|---------------|
| Drive URL / token | token 或类型不确定时,使用 `drive +inspect`。 |
| Wiki URL / token | 使用 `drive +inspect``wiki +node-get`;保留节点身份和对象身份。 |
| 文件夹候选 | 标记为容器;不要当作普通文档做内容验证。 |
| 快捷方式候选 | 能解析源资源时解析源资源;同时保留快捷方式身份。 |
| 无读取权限 | 保留可见元数据,并设置 `permission_state=denied`。 |
| 无移动权限或移动权限未知 | 保留可见元数据和召回证据,并设置对应 `move_permission_state`。 |
| 无法解析当前父级 | 设置 `current_parent_kind=unknown`,保留已知路径,后续计划项设置 `rollback_supported=false` 和明确 blocker不得编造父级 token。 |
### 资源解析进度 UI
`RESOURCE_RESOLVE` 持续超过约 60 秒时,输出当前进度:
```text
资源解析进度:已解析 <resolved_count>/<total_count> 项,已确认可移动 <movable_count> 项,无移动权限 <denied_count> 项,移动权限未知 <unknown_count> 项,解析失败 <failed_count> 项。
当前资源:<title>
继续解析中,不会创建或移动资源。
```
如果正在处理权限或 owner 元数据,可补充:
```text
当前步骤:解析 owner / 当前父级 / 移动资格。
```
`RESOURCE_RESOLVE` 完成后,输出摘要:
```text
资源解析完成:
- 候选总数N 项
- 可进入内容验证N 项
- 无移动权限N 项
- 移动权限未知N 项
- 解析失败或无读取权限N 项
下一步会对可移动资源做内容验证;不会创建或移动资源。
```
### 资源解析出口门禁
`RESOURCE_RESOLVE` 完成后必须:
1.`content_verify_completed` 重置为 `false`
2. 将下一状态设置为 `CONTENT_VERIFY`,不得设置为 `RELEVANCE_CLASSIFY``PLAN_MOVE`
3. 不得在本状态生成 `relevance``relevance_groups` 或移动计划。
4. 即使可读取正文的资源数量为 0也必须进入 `CONTENT_VERIFY`,为每项记录跳过验证原因并输出验证摘要。
### 移动资格判定
`owner` 只能作为部分权限证据,不得单独把资源判为 `movable``RESOURCE_RESOLVE` 必须先按 `move_method` 记录以下独立状态:
| 字段 | 说明 |
|------|------|
| `source_move_state` | 当前身份是否确认可以对源资源执行对应移动Drive owner 只可作为 Drive 源资源可管理的证据Wiki 底层资源 owner 不能证明 Wiki 节点可移动。 |
| `source_parent_write_state` | 当前身份是否确认可编辑源位置;仅 `drive_move` 必须确认,其他移动方式为 `not_required`。 |
| `target_write_state` | 当前身份是否确认可写目标位置;待创建目标以父级位置的创建 / 写入权限为准。 |
#### 按移动方式的权限矩阵
| `move_method` | `source_move_state=confirmed` 的证据 | `source_parent_write_state` | `target_write_state` |
|---------------|--------------------------------------|-----------------------------|----------------------|
| `drive_move` | 当前用户是可靠解析出的 Drive 资源 owner或有明确资源可管理证据 | 必须为 `confirmed` | 必须为 `confirmed` |
| `wiki_move_docs_to_wiki` | 有明确的 Drive 文档直接迁入权限;仅 owner 元数据不足以证明可直接迁入 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_node` | 有明确的 Wiki 节点 / 源空间移动权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_to_drive` | 有明确的 Wiki 节点移出权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Drive 文件夹可写 |
#### 聚合顺序
1. 目标方向或资源类型不支持时,设置 `move_permission_state=denied``move_permission_basis=["unsupported_direction"]`
2. 任一必需状态为 `denied` 时,设置 `move_permission_state=denied`,并在 `move_permission_basis` 记录 `source_denied``source_parent_denied``target_denied`
3. 任一必需状态为 `unknown` 时,设置 `move_permission_state=unknown`,并记录对应的 `source_unknown``source_parent_unknown``target_unknown`
4. 只有权限矩阵中的全部必需状态都为 `confirmed` 时,才能设置 `move_permission_state=movable``move_permission_basis=["permission_matrix_confirmed"]`
注意:
1. `drive permission.members auth` 不提供 `full_access``move` action不能用 `view``edit``share``manage_public` 结果推断源位置或目标位置可写。
2. `target_write_state=unknown|denied` 的资源不得进入高 / 中相关可执行分组或移动计划。
3. `move_permission_state=unknown` 的资源默认不进入内容验证、相关性高 / 中分组或移动计划。
4.`owner_scope=mine` 但解析出的 owner 不是当前用户时,将该资源视为异常候选,设置 `source_move_state=unknown``move_permission_state=unknown`,不得加入移动计划。
## 状态:`CONTENT_VERIFY`
进入条件:资源列表已准备。
必须:
1. 本状态不可跳过,也不得与 `RESOURCE_RESOLVE``RELEVANCE_CLASSIFY` 合并;没有可读取正文的资源时仍须执行。
2. 只在资源解析后读取支持的内容。
3. 按数量、大小和类型能力限制读取范围。
4. 结合搜索证据和内容证据;除非标题精确且足够强,否则不要仅凭标题判为高相关。
5. 将不可读取资源标记为 `unverifiable``permission_denied`
6. 不得自动申请权限。
7. 为每个资源写入验证状态:已读取内容证据、仅可使用搜索证据、无权限、无移动权限、移动权限未知、无法验证或不支持内容验证。
8.`move_permission_state=denied|unknown` 的资源,不再读取正文内容,写入跳过验证原因并保留召回证据;写入跳过原因属于执行本状态,不等于跳过本状态。
9. 所有资源都有验证状态或跳过原因后,将 `content_verify_completed` 设置为 `true` 并输出验证摘要。
10. `content_verify_completed=true` 前不得进入 `RELEVANCE_CLASSIFY`
### 验证方式
| 资源类型 | 验证方式 |
|---------------|---------------------|
| `docx` / `doc` | 允许时使用 `docs +fetch --api-version v2`。 |
| `sheet` | 使用 `sheets +find` 查关键词证据,或用 `sheets +read` 读取有界范围。 |
| `bitable` | 只有必要且已加载 Base 能力时验证。 |
| `slides` | 除非具备幻灯片读取能力,否则使用元数据 / 预览 / 标题证据。 |
| `file` | 仅在支持时使用标题、元数据、预览或导出文本。 |
| `wiki` 节点 | 按 `obj_type` 验证底层对象;节点本身不是内容 token。 |
| `folder` | 除非用户明确要移动容器,否则通常不作为主题证据移动。 |
### 内容验证完成 UI
完成 `CONTENT_VERIFY` 后必须输出:
```text
内容验证完成:
- 已读取内容证据N 项
- 仅复用搜索证据N 项
- 因无权限或移动资格跳过N 项
- 无法验证或不支持验证N 项
下一步会基于以上证据进行相关性分组;不会创建或移动资源。
```
如果没有任何资源可以读取正文,仍须输出该摘要,并明确说明所有资源采用的搜索证据或跳过原因。
### 内容验证出口门禁
`CONTENT_VERIFY` 完成后必须:
1. 确认 `content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过原因。
2. 将下一状态设置为 `RELEVANCE_CLASSIFY`
3. 加载 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md)。
4. 不得直接进入 `PLAN_MOVE`
## ResourceItem
```json
{
"resource_id": "稳定资源 ID",
"title": "资源标题",
"resource_type": "doc|docx|sheet|bitable|file|folder|wiki|slides|shortcut",
"url": "资源链接",
"canonical_token": "标准资源 token",
"wiki_node_token": "Wiki 节点 token",
"wiki_obj_token": "Wiki 底层对象 token",
"wiki_obj_type": "Wiki 底层对象类型",
"space_id": "知识空间 ID",
"current_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"current_parent_token": "当前父级 token",
"current_parent_space_id": "当前父级 Wiki space_id",
"current_path": "用于展示的当前位置",
"owner_id": "资源 owner open_id",
"is_owner": "true|false|unknown",
"permission_state": "readable|denied|unknown",
"source_move_state": "confirmed|unknown|denied",
"source_parent_write_state": "confirmed|unknown|denied|not_required",
"move_permission_state": "movable|denied|unknown",
"move_permission_basis": ["权限矩阵证据或阻塞原因"],
"target_write_state": "confirmed|unknown|denied",
"item_resolve_status": "resolved|partial|failed",
"content_verify_state": "verified|search_evidence_only|skipped_by_move_permission|permission_denied|unverifiable|unsupported",
"content_evidence": ["证据"],
"relevance": "high|medium|low|permission_denied|no_move_permission|move_permission_unknown|unverifiable|unsupported_move_target"
}
```
| 字段 | 说明 |
|-------|------|
| `canonical_token` | 内容读取、Drive 对象操作或底层对象操作使用的标准 tokenWiki 节点移动不得使用该字段。 |
| `resource_id` | 资源解析时生成的稳定 ID用于连接 `ResourceItem``MovePlanItem`。 |
| `wiki_node_token` | Wiki 节点身份,用于 Wiki 节点移动。 |
| `wiki_obj_token` | Wiki 节点背后的真实文档 token。 |
| `current_parent_kind` / `current_parent_token` / `current_parent_space_id` | 结构化执行前父级,用于 `already_at_target` 判断和恢复;未知值不得猜测。 |
| `current_path` | 仅用于用户展示的当前位置,不得代替父级 token。 |
| `owner_id` | 资源 ownerDrive 资源优先来自 `drive metas batch_query`Wiki 节点优先来自 `wiki +node-get`。 |
| `is_owner` | 当前用户是否为资源 owner。 |
| `permission_state` | 当前身份下的读取权限状态。 |
| `source_move_state` | 当前身份是否确认能对源资源执行所选 `move_method`;必须按权限矩阵判断。 |
| `source_parent_write_state` | Drive 内移动所需的源位置编辑状态;非 `drive_move``not_required`。 |
| `move_permission_state` | 权限矩阵聚合结果;只有 `movable` 且目标写入状态为 `confirmed` 才可进入默认移动链路。 |
| `move_permission_basis` | 移动资格判断依据,用于解释为什么纳入或排除。 |
| `target_write_state` | 目标位置是否确认可写。 |
| `item_resolve_status` | 资源项解析状态;不要和 `TargetLocation.target_resolve_status` 混用。 |
| `content_verify_state` | 内容验证状态或跳过验证原因。 |
| `content_evidence` | 支撑相关性判断的命中证据。 |
| `relevance` | 相关性和可执行性分组。 |

View File

@@ -0,0 +1,248 @@
# 主题资料收集工作流:审核与计划
由状态 `RELEVANCE_CLASSIFY``PLAN_MOVE` 加载。
本文档负责相关性分级、审核 UI、移动计划生成和 `MovePlanItem`。不得重新执行资源解析或内容验证,也不得创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 输入契约
进入本文档前必须已有:
1. `resource_items`,且每个 `ResourceItem` 已包含稳定 `resource_id`、资源类型、移动所需 token、结构化当前父级、权限状态、内容验证状态和证据。
2. `content_verify_completed=true`
3. 每个资源都有内容证据、搜索证据复用说明或明确跳过原因。
`ResourceItem` schema 和字段生成规则由 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 负责。只要上述输入契约完整,本状态不得为重复读取 schema 而重新加载或执行前一阶段文档。
如果输入字段缺失、资源需要重新解析或用户要求重新读取证据,废弃受影响的相关性和计划结果,返回 `RESOURCE_RESOLVE``CONTENT_VERIFY`,并加载资源解析与内容验证文档;不得在本状态补猜。
## 状态:`RELEVANCE_CLASSIFY`
进入条件:`CONTENT_VERIFY` 已完成,`content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过验证原因。
禁止条件:
1. 只有 `candidate_items`,没有 `resource_items`
2. 资源未经过 `RESOURCE_RESOLVE`
3. 资源没有 `RESOURCE_RESOLVE` 写入的移动资格状态。
4. 资源没有 `CONTENT_VERIFY` 写入的验证状态或跳过验证原因。
5. 上一完成状态是 `RESOURCE_RESOLVE`,或 `content_verify_completed` 不为 `true`
必须将每个资源归入且只归入一个分组:
| 分组 | 说明 | 默认移动 |
|-------|------|--------------|
| `high` | 可移动资源,且主题或内容直接命中,有明确标题 / 正文 / 表格 / 评论证据。 | 是 |
| `medium` | 可移动资源,可能相关,但证据不足或只命中弱相关片段。 | 否,需用户选择 |
| `low` | 可移动资源,弱相关或噪声,保留展示但不建议移动。 | 否 |
| `permission_denied` | 当前身份无权读取或解析,不能验证内容。 | 否 |
| `no_move_permission` | 已确认当前身份不具备移动资格。 | 否 |
| `move_permission_unknown` | 无法确认当前身份是否具备移动资格。 | 否 |
| `unverifiable` | 类型或工具限制导致无法验证内容。 | 否 |
| `unsupported_move_target` | 目标方向或资源类型不支持移动。 | 否 |
`high``medium``low` 只能包含 `move_permission_state=movable``target_write_state=confirmed` 的资源。
判为高相关至少需要一个强证据:
1. 标题或内容中出现精确主题短语。
2. 多个主题词在相关上下文中同时出现。
3. Sheet / 表格单元格明确匹配用户主题。
4. 用户明确提供的文档名或项目别名命中。
中相关示例:
1. 标题包含一个主题词,但内容无法确认。
2. 搜索摘要看起来相关,但无法完整读取。
3. 别名命中合理但证据不够强。
## 审核 UI
必须展示每个分组中的资源名称。
默认展示规则:
1. 展开 `high``medium`
2. 折叠 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`,但展示数量并允许展开。
3. 每个可见资源展示标题、类型、当前位置、证据和默认动作。
4. 除非用户要求技术细节,否则不展示原始 token。
示例:
```text
筛选结果:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关(默认移动):
- 标题|类型|证据|当前位置
中相关(需你勾选后才移动):
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
你可以选择:
1. 确认按默认规则生成移动计划。
2. 勾选要加入计划的中相关资源。
3. 要求把某些资源移到其他分组或从计划中移除。
4. 展开低相关 / 无权限 / 无移动权限 / 移动权限未知 / 无法验证 / 不支持移动分组查看名称。
```
### 用户调整规则
如果用户不同意相关性结果,必须基于用户要求更新 `relevance_groups`,再重新展示分组结果并重新生成后续移动计划。
典型调整包括:
1.`high` 中移除某个资源。
2.`medium` 中某个资源提升为 `high`
3. 将某个资源标为 `low` 或不移动。
4. 要求重新读取证据或重新判断一批资源。
5. 要求重新确认某些资源的移动权限。
用户调整后:
1. 旧的 `move_plan_items` 立即失效。
2. 必须先输出“调整后相关性结果”,展示被调整项、各分组数量和高 / 中相关资源名称。
3. 不得只回复“已调整”,也不得直接跳到 `CONFIRM_EXECUTION`
4. 必须基于新的 `relevance_groups` 重新执行 `PLAN_MOVE`
5. 不得把 `no_move_permission``move_permission_unknown` 资源直接提升到 `high` / `medium`;必须先回到 `RESOURCE_RESOLVE`,加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 取得可移动证据。
### 调整后结果 UI
```text
已按你的要求调整相关性结果:
- <标题><原分组> -> <新分组>
调整后分组:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关默认移动N 项
- 标题|类型|证据|当前位置
中相关需你勾选后才移动N 项
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
接下来会基于这个调整后的结果重新生成移动计划;你也可以继续调整。
```
## 状态:`PLAN_MOVE`
进入条件:相关性分组已准备。
必须:
1.`target_location.create_required=true` 时,纳入目标创建计划。
2. 生成移动计划前,比较规范化的当前父级与目标父级;已在目标位置的资源生成 `skip_resource`,设置 `skip_reason=already_at_target`,不得生成移动命令。
3. 默认纳入全部 `high``move_permission_state=movable``target_write_state=confirmed` 的资源。
4. 只有用户明确选择时,才纳入 `medium``move_permission_state=movable``target_write_state=confirmed` 的资源。
5. 默认排除 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`
6. 为每个跳过项生成 `skip_reason`
7. 为每个计划项生成稳定 `plan_id`,并使用 `resource_id` 连接对应资源;不得按标题或临时 token 猜测关联。
8.`command_family` 保存完整、不可变的 `command_args`;不得把 Wiki 底层对象 token 当作 Wiki 节点移动 token。
9. 为每个 `move_resource` 项复制执行前恢复所需的完整 `rollback_input`,使确认计划不依赖运行时回查 `ResourceItem`
10. 当前父级无法结构化解析或属于 Drive / Wiki 跨容器移动时,设置 `rollback_supported=false` 和明确 `rollback_blocker`;该单项仍可进入确认,但必须逐项展示不可恢复风险,不得阻塞其他独立项。
11. 停止并等待用户选择或执行意图。
12. 不得为 `move_permission_state!=movable``target_write_state!=confirmed` 的资源生成 `move_resource` 计划项。
### 已在目标位置判定
1. `drive_move` 比较 `current_parent_kind` 和目标 Drive 父级,并比较规范化后的 `current_parent_token` / root 标识。
2. `wiki_move_node` 比较 `current_parent_space_id``current_parent_kind``current_parent_token`Wiki 空间根节点使用明确的 root 标识,不得用空字符串和未知状态混淆。
3. 只有父级类型、space ID适用时和 token 都已解析且相等时,才能设置 `skip_reason=already_at_target`;父级未知时不得猜测为相等。
### 移动 token 选择
| `command_family` | `command_args` 必须包含 |
|------------------|---------------------------|
| `drive +move` | `file_token``type``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空且目标类型为 root。 |
| `wiki +move`node | `node_token`,以及 `target_space_id``target_parent_token`;可选 `source_space_id`。不得使用 `wiki_obj_token` 代替 `node_token`。 |
| `wiki +move`docs-to-wiki | `obj_type``obj_token``target_space_id`、可选 `target_parent_token`,并显式保存 `apply=false`。 |
| `wiki +move-to-drive` | `node_token``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空。 |
| `drive +create-folder` | `name`、父级 `folder_token`;创建在 Drive root 时显式记录父级为空。 |
| `wiki +node-create` | `space_id``title``obj_type`、可选 `parent_node_token`。 |
| `none` | 不执行命令,保留 `skip_reason`。 |
目标由本次 workflow 创建时,对应目标参数保存 `created_by_plan:<create_target plan_id>` 引用。`EXECUTE` 只允许把该引用替换为对应创建计划返回的 token不得重新搜索或猜测目标。
### 计划 UI
```text
移动计划已生成:
- 默认将移动高相关N 项
- 你已选择中相关N 项
- 其中不可自动恢复N 项
- 已在目标位置N 项
- 不会移动N 项
- 无移动权限N 项
- 移动权限未知N 项
你可以回复“确认执行”,也可以继续调整分组、增减中相关资源,或取消本次移动。
```
## MovePlanItem
```json
{
"plan_id": "稳定计划项 ID",
"resource_id": "对应 ResourceItem.resource_idcreate_target 为空",
"action_type": "create_target|move_resource|skip_resource|unsupported",
"title": "资源或目标名称",
"resource_type": "源资源类型",
"move_method": "drive_move|wiki_move_node|wiki_move_docs_to_wiki|wiki_move_to_drive|none",
"command_family": "具体 shortcut 命令或 none",
"command_args": {
"<arg>": "按 command_family 参数表保存的完整、类型明确的参数"
},
"source_path": "用户确认时展示的源位置",
"target_path": "用户确认时展示的目标位置",
"move_permission_state": "movable|denied|unknown|not_required",
"target_write_state": "confirmed|unknown|denied",
"reason": "纳入或跳过原因",
"skip_reason": "already_at_target 或其他跳过原因",
"rollback_input": {
"source_kind": "drive|wiki",
"original_token": "原始 Drive / obj token",
"original_node_token": "原始 Wiki node token",
"resource_type": "恢复命令需要的资源类型",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径"
},
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因",
"execution_status": "pending|success|failed|skipped"
}
```
| 字段 | 说明 |
|-------|------|
| `plan_id` | 稳定计划项 ID用于连接计划、快照和执行日志。 |
| `resource_id` | 稳定资源 ID用于连接确认计划和解析结果`create_target` 为空。执行阶段不得依赖该关联回查可变参数。 |
| `action_type` | 计划动作类型。 |
| `move_method` | 实际使用的移动方式。 |
| `command_family` / `command_args` | 用户确认的完整写命令及参数快照;确认后保持不可变。目标待创建时只允许使用 `created_by_plan:<plan_id>` 引用。 |
| `move_permission_state` / `target_write_state` | 用户确认时的权限门禁快照;`move_resource` 必须分别为 `movable` / `confirmed``create_target` 的移动权限为 `not_required`,但父级写入权限仍必须为 `confirmed`。 |
| `rollback_input` | 从 `ResourceItem` 复制出的完整恢复输入;仅 `move_resource` 必填,生成确认计划后不得再回查或猜测。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因;跨容器移动使用 `cross_container_permission_model_not_losslessly_restorable`,原父级 token 缺失使用 `original_parent_token_unavailable`。 |
| `execution_status` | 执行状态。 |

View File

@@ -0,0 +1,174 @@
# 主题资料收集工作流:输入与目标确认
由状态 `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT` 加载。
本文档负责用户输入解析、目标位置解析、搜索前确认和 `TargetLocation`。不得执行搜索召回、资源分类、目标创建或资源移动。
本文档只服务 `topic_move_collector`。进入本文档后必须确认 `workflow_id=topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 解析 Drive 目标时,遵循 [`lark-drive-inspect.md`](lark-drive-inspect.md)、[`lark-drive-create-folder.md`](lark-drive-create-folder.md) 和 [`lark-drive-search.md`](lark-drive-search.md)。
3. 解析 Wiki 目标时,遵循 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)、[`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 和 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md)。
## 状态:`PARSE_INPUT`
进入条件workflow 被触发。
必须:
1. 提取 `topic``target``identity``owner_scope``constraints`
2.`topic``target` 视为必填字段。
3. 除非用户明确要求 bot / app 视角,否则 `identity` 默认使用用户身份。
4. 默认 `allow_cross_container_move=true`,但必须在 `CONFIRM_CONTEXT` 展示。
5. 默认 `owner_scope=mine`,表示只搜索当前用户 owner / 负责的资源。
6. 只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才设置 `owner_scope=all_visible`
7. 除非用户明确提供限制,否则 `constraints` 保持为空。
8. 如果缺少 `topic``target`,只提出最小澄清问题。
### 输入字段
| 字段 | 说明 |
|-------|------|
| `topic` | 用户要查找的主题、关键词、内容线索、同义词、缩写、排除词。 |
| `target` | 归档目标,可以是已有 Drive 文件夹、已有 Wiki 节点、待创建 Drive 文件夹或待创建 Wiki 节点。 |
| `identity` | 执行身份,默认 `--as user`。 |
| `owner_scope` | 搜索 owner 范围,默认 `mine``all_visible` 仅在用户明确要求扩展到所有可见资源时使用。 |
| `constraints` | 用户显式给出的类型、时间、创建人、评论、标题、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须确认。 |
### 澄清模板
```text
我还需要补齐两个信息后才能开始:
1. 要查找的主题 / 关键词 / 内容线索是什么?
2. 找到后要移动到哪个 Drive 文件夹或 Wiki 节点?如果需要新建目标,也请说明父级位置和新名称。
```
## 状态:`RESOLVE_TARGET`
进入条件:`topic``target` 已获得。
必须:
1. 将已有目标解析为具体 token。
2. 如果目标需要创建,只解析父级位置和新目标名称。
3. 在本状态中不得创建文件夹或 Wiki 节点。
4. 分别保留 Drive 文件夹 token、Wiki 节点 token、Wiki 对象 token、space ID 和 parent token。
5. 如果目标 URL / token 存在,但当前身份无法读取或解析目标位置,设置 `target_resolve_status=permission_denied`,保持在 `RESOLVE_TARGET` 并等待用户更换目标或结束;不得进入搜索。
6. 如果已知移动方向不支持,尽早标记。
### 目标解析
| 条件 | agent 必须执行 | 设置 `target_type` |
|-----------|---------------|-------------------|
| 已有 Drive 文件夹 URL 或 token | 有 URL 时用 `drive +inspect` 解析;保留 `folder_token` | `drive_folder` |
| 已有 Wiki 节点 URL 或 token | 用 `wiki +node-get``drive +inspect` 解析;保留 `wiki_node_token``space_id` | `wiki_node` |
| 在已知父级下新建 Drive 文件夹 | 解析父文件夹;保存新文件夹名称;不创建 | `new_drive_folder` |
| 在已知父级下新建 Wiki 节点 | 解析知识空间和可选父节点;保存新节点标题;不创建 | `new_wiki_node` |
| 以 Wiki 空间根节点作为目标 | 解析 `space_id`parent token 可以为空 | `wiki_space` |
| 目标名称有歧义 | 仅在必要时搜索或列出候选;展示候选并等待用户选择 | `unknown` |
### 目标解析状态
| 条件 | `target_resolve_status` |
|------|--------------------------|
| 目标已解析,或待创建目标的父级位置已解析 | `resolved` |
| 目标名称有歧义、候选不唯一,或 `target_type=unknown` 需要用户选择 | `ambiguous` |
| 已知目标方向或目标类型不支持本 workflow | `unsupported` |
| 目标 URL / token 存在,但当前身份无权读取、解析或确认目标位置 | `permission_denied` |
### 目标解析出口门禁
| `target_resolve_status` | 下一状态 | agent 必须执行 |
|-------------------------|----------|----------------|
| `resolved` | `CONFIRM_CONTEXT` | 展示已解析目标并进入搜索前确认。 |
| `ambiguous` | 保持 `RESOLVE_TARGET` | 展示候选并等待用户选择;不得进入 `CONFIRM_CONTEXT`。 |
| `unsupported` | 保持 `RESOLVE_TARGET` | 展示不支持原因,等待用户更换目标或结束;不得搜索。 |
| `permission_denied` | 保持 `RESOLVE_TARGET` | 展示权限 blocker等待用户更换目标或结束不得搜索。 |
用户提供新目标后,重新执行 `RESOLVE_TARGET`。只有新的解析结果为 `resolved`,才能进入 `CONFIRM_CONTEXT`;用户选择结束时进入 `DONE`
### 跨容器规则
| 来源 -> 目标 | 默认规则 |
|------------------|---------|
| Drive 资源 -> Drive 文件夹 | 支持,使用 `drive +move`。 |
| Drive 文档类资源 -> Wiki 节点 / 空间 | 资源类型支持时,使用 `wiki +move`。 |
| Wiki 节点 -> Wiki 节点 / 空间 | 支持,使用 `wiki +move --node-token`。 |
| Wiki 节点 -> Drive 文件夹 | `wiki +move-to-drive`。 |
## 状态:`CONFIRM_CONTEXT`
进入条件:`target_resolve_status=resolved`
必须:
1. 展示主题、目标、身份、搜索 owner 范围、限制和目标解析字段。
2. 说明下一步只进行搜索 / 读取。
3. 说明是否计划创建目标,但尚未执行。
4. 展示是否允许跨容器移动。
5. 在进入 `SEARCH_RECALL` 前停止并等待用户确认。
6. 如果 `owner_scope=all_visible`,明确提示候选数量可能较多,且可能包含无法移动的资源。
### 确认 UI
```text
我先确认本次收集任务。
查找主题:
目标位置:
目标解析:
执行身份:
搜索范围:
可选限制:
跨容器移动:
下一步操作:只进行搜索和读取验证,不创建目标,不移动资源。
请确认是否按以上信息开始搜索?
```
默认搜索范围文案:
```text
搜索范围:当前用户 owner / 负责的资源
```
扩展搜索范围文案:
```text
搜索范围:所有当前身份可见资源
风险提示:候选数量可能较多,且部分资源可能无法移动;后续仍会经过资源解析和内容验证。
```
如果用户修改任一字段,更新 `topic``target_location``owner_scope``constraints`,然后只重新执行受影响的 setup 状态,再次展示确认信息。
## TargetLocation
```json
{
"target_type": "drive_folder|wiki_node|wiki_space|new_drive_folder|new_wiki_node|unknown",
"target_token": "已有目标的 folder_token 或 wiki_node_token",
"parent_token": "待创建目标的父级 folder_token 或 wiki_node_token",
"space_id": "知识库空间 ID",
"target_name": "待创建目标名称",
"create_required": false,
"allow_cross_container_move": true,
"target_resolve_status": "resolved|ambiguous|unsupported|permission_denied"
}
```
| 字段 | 说明 |
|-------|------|
| `target_type` | 目标位置类型,用于决定后续创建和移动命令。 |
| `target_token` | 已有目标的可执行 token。 |
| `parent_token` | 待创建目标的父级位置 token。 |
| `space_id` | Wiki 目标所属知识空间 ID。 |
| `target_name` | 待创建目标的名称。 |
| `create_required` | 是否需要在 `EXECUTE` 阶段创建目标。 |
| `allow_cross_container_move` | 是否允许 Drive / Wiki 之间移动。 |
| `target_resolve_status` | 目标位置解析状态;不要和 `ResourceItem.item_resolve_status` 混用。 |

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