Compare commits

...

21 Commits

Author SHA1 Message Date
bubbmon233
f495cbb166 feat(mail): add message modify and trash shortcuts (#1567) 2026-07-07 21:44:40 +08:00
Yuxuan Zhao
6f95c5eb22 e2e: harden CLI E2E retry, cleanup, and domain selection (#1709) 2026-07-07 19:41:11 +08:00
liangshuo-1
4e2cbea94e chore: release v1.0.66 (#1782) 2026-07-07 19:33:39 +08:00
fangshuyu-768
f98dbfe247 Improve agent-facing error guidance for drive, markdown, and wiki (#1779) 2026-07-07 18:10:23 +08:00
hugang-lark
40ea4d60ef feat: minute wait (#1768)
feat: remove reference lark-sharded skill

docs: clarify calendar write feedback

docs: split calendar scheduling workflow

docs: tighten calendar skill references

docs: annotate +freebusy scope to avoid unnecessary reads in scheduling flow

When users express "find free time + create event" intent, AI would
previously read freebusy.md before entering the scheduling workflow.
Adding a scope note to the shortcut table directs AI to use +suggestion
instead, reducing token consumption.

feat: support calendar +get

fix: optimize calendar skill

fix: optimize shortcuts and meta api

fix: optimize skill

Co-authored-by: calendar-assistant <tangfengyuan@bytedance.com>
2026-07-07 17:12:28 +08:00
liangshuo-1
f0b6f35fee fix: resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
* fix(schema): fall back to runtime catalog when no embedded metadata

Binaries built from the bare Go module (plugin builds) embed only the
empty meta_data_default.json stub because meta_data.json is gitignored
and fetched at build time. The schema command, its completion, and the
affordance command-form resolver read the embedded-only catalog, so
every schema lookup failed with "Unknown service" even though the
runtime registry had already sync-fetched full metadata.

Add registry.SchemaCatalog(): embedded when compiled in (official
builds unchanged, still deterministic), otherwise the merged runtime
catalog seeded from cache or remote fetch. When neither source has
data (offline plugin build with a cold cache), schema now returns a
failed_precondition error with an actionable hint instead of
"Unknown service" with an empty candidate list.

* fix(registry): gate cached meta overlay on version newer than embedded

The cached remote meta was overlaid onto the embedded meta_data.json
unconditionally, so after a CLI upgrade an equal- or older-version
cache kept shadowing the freshly shipped embedded definitions until a
later refresh happened to rewrite it.

Only overlay when the cache version is strictly newer than the
embedded baseline. The bare-module stub baseline is "0.0.0", so plugin
builds without compiled metadata still take any real cached version
(TestOverlayGate_StubEmbedded_OverlaysRealCache) and the schema
runtime fallback keeps working offline from a warm cache.

Ports #1376 onto the typed meta model.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:24:24 +08:00
liangshuo-1
91d785f92f refactor(envvars): consolidate agent env value access (#1757)
* feat(envvars): add LARKSUITE_CLI_AGENT_NAME accessor

envvars.AgentName() reads LARKSUITE_CLI_AGENT_NAME so business code can identify the agent driving the CLI. Value is sanitised (trim, reject control chars, 128-byte cap) and returns "" when unset or invalid. Local-only; not sent to the server.

* refactor(envvars): consolidate agent env accessors, dedupe sanitize

Move agent-trace reading from cmdutil.AgentTraceValue into envvars.AgentTrace(), symmetric with AgentName and sharing one sanitizeSingleLine. secheader.go now only assembles headers (BaseSecurityHeaders calls envvars.AgentTrace()); the duplicated sanitize and its os/unicode imports are gone. The X-Agent-Trace header and its behaviour are unchanged.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:17:51 +08:00
leave330
e621c6e50f fix: support pnpm global installs in self-update (#1705) 2026-07-06 21:11:01 +08:00
Zee Zheng
869a259d4e docs: clarify success envelope contract — judge success by ok, not code (#1730)
The stdout success envelope is {ok, identity, data, meta?} and carries no
top-level code/msg field, but wrappers following the raw OpenAPI convention
test code == 0 and misclassify every successful call as a failure. Around
write commands this defeats caller-side idempotency and causes duplicate
creates (see #1689).

Document the contract in ERROR_CONTRACT.md (success-envelope counterpart to
the error wire format), both READMEs (JSON Output Contract section), the
lark-shared skill (shared prerequisite for all skills), and add a success
response example to lark-task-create.md.

Closes #1689
2026-07-06 20:03:05 +08:00
fangshuyu-768
ee46e22abd docs: tighten doc creation validation workflow (#1759) 2026-07-06 17:07:10 +08:00
hhang
b76dc18c2f fix(calendar): guide approval room booking fallback (#1637) 2026-07-06 15:04:50 +08:00
calendar-assistant
85679d4258 feat: support semantic recurring calendar operations (#1723) 2026-07-06 14:10:57 +08:00
wangweiming-01
1ba4f3973c fix: guide drive import concurrency conflicts (#1751) 2026-07-06 10:46:46 +08:00
liangshuo-1
c45ff569c4 chore: release v1.0.65 (#1742) 2026-07-03 20:25:22 +08:00
caojie0621
a1506cdffb feat: add docs history shortcuts (#1612)
Add docs +history-list, +history-revert, and +history-revert-status backed by docs_ai history OpenAPI endpoints.

Document the safe history workflow and extend dry-run/live E2E coverage for the new shortcuts.
2026-07-03 16:21:18 +08:00
liuxin-0319
3595356ea1 chore: sync lark-doc skill from online-doc (#1701) 2026-07-03 15:55:46 +08:00
zhangjun-bytedance
73be1d06ec bugfix 0702 about speaker replace (#1731) 2026-07-03 14:05:48 +08:00
liujinkun2025
cccf025599 docs(drive): document 30-char query limit for +search (#1560)
The Search v2 API rejects queries longer than 30 characters (counted by
Unicode code point, CJK 1 each) with 99992402 field validation failed —
it is a hard error, not truncation. Surface this in the --query -h help
text and the lark-drive search skill so callers compress long queries
before searching instead of hitting the error.

Change-Id: Ieb30a66edae7a573690c49719627ec8fb2500a1a
2026-07-03 11:39:07 +08:00
zhanglei-1103
7db899db01 docs: add mindnote guidance to lark-doc (#1581) 2026-07-03 10:50:25 +08:00
liangshuo-1
c2d6038aae chore: release v1.0.64 (#1725) 2026-07-02 21:58:37 +08:00
Lekko
efa3439e01 fix(update): increase npm registry fetch timeout from 5s to 15s (#1724)
The update checker fetches https://registry.npmjs.org/@larksuite/cli/latest
with a 5-second HTTP client timeout. Under high-latency network conditions
(TUN-mode proxies, VPNs, transcontinental routes), TLS handshake alone can
take 4-6 seconds, causing the check to fail with:

  context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Measured example behind a Clash TUN proxy (US node from China):
  DNS resolve:    ~0ms (fake-ip)
  TCP connect:    ~0ms (local TUN)
  TLS handshake:  4.3-5.9s  <-- bottleneck
  Total:          4.7-6.3s

curl succeeds because it has no default connect timeout, but the Go HTTP
client with Timeout=5s is too tight. The registry endpoint returns a tiny
JSON payload (<1KB), so 15s is more than enough headroom while still
failing fast on genuinely unreachable networks.

Co-authored-by: 王伟达 <weida.wang@m.com>
2026-07-02 21:36:44 +08:00
134 changed files with 6906 additions and 1482 deletions

View File

@@ -263,13 +263,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version: '3.x' python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli - name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build run: make build
- name: Run dry-run E2E tests - name: Run dry-run E2E tests
env: env:
@@ -277,7 +283,28 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression' E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
e2e-live: e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate] needs: [unit-test, lint, script-test, deterministic-gate]
@@ -292,15 +319,22 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }} TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version: '3.x' python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli - name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build run: make build
- name: Configure bot credentials - name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: | run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -310,16 +344,24 @@ jobs:
- name: Run CLI E2E tests - name: Run CLI E2E tests
env: env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: | run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$') if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions." echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
exit 1 exit 1
fi fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -) echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report - name: Publish CLI E2E test report
if: ${{ !cancelled() }} if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with: with:
name: CLI E2E Tests name: CLI E2E Tests

3
.gitignore vendored
View File

@@ -27,6 +27,9 @@ Thumbs.db
# Go # Go
docs/ref docs/ref
docs/ docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/ vendor/

View File

@@ -2,6 +2,71 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.0.66] - 2026-07-07
### Features
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01 ## [v1.0.62] - 2026-07-01
### Features ### Features
@@ -1333,6 +1398,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62 [v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61 [v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60 [v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

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

View File

@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values --format csv # Comma-separated values
``` ```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination ### Pagination
```bash ```bash

View File

@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值 --format csv # 逗号分隔值
``` ```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页 ### 分页
```bash ```bash

View File

@@ -20,13 +20,28 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) { func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err) t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
fn := cmd.ValidArgsFunction fn := cmd.ValidArgsFunction
tests := []struct { tests := []struct {
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
flag := cmd.Flags().Lookup("as") flag := cmd.Flags().Lookup("as")
if flag == nil { if flag == nil {
t.Fatal("expected --as flag to be registered") t.Fatal("expected --as flag to be registered")
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return apiRun(opts) return apiRun(opts)
}) })
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream", ContentType: "application/octet-stream",
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute() err := cmd.Execute()
// Should return an error // Should return an error
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute() err := root.Execute()
if err == nil { if err == nil {
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"}) cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })

View File

@@ -14,11 +14,13 @@ import (
"github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
} }
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper() t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"} rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true rootCmd.SilenceErrors = true
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
} }
rootCmd.AddCommand(auth.NewCmdAuth(f)) rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil)) rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f) if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
shortcuts.RegisterShortcuts(rootCmd, f) shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() { if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode) pruneForStrictMode(rootCmd, mode)
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
return rootCmd return rootCmd
} }
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper() t.Helper()
t.Setenv(envvars.CliAppID, "") t.Setenv(envvars.CliAppID, "")
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) { func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f) catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{ code := executeRootIntegration(t, f, rootCmd, []string{
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run", "fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
}) })
if code != output.ExitValidation { if code != output.ExitValidation {

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd return cmd
} }
// completeSchemaPath is a thin adapter over the embedded catalog's Complete. // completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the embedded source so completion candidates match what `schema` // It uses the same source as schema execution so completion candidates match
// execution can resolve (both overlay-free). // what `schema` can resolve.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context()) mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp directive := cobra.ShellCompDirectiveNoFileComp
if noSpace { if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode) return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
} }
// runSchema resolves the path through the embedded catalog and renders the // runSchema resolves the path through the schema catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and // matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the // schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object, // output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints. // anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error { func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog() catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
target, err := catalog.Resolve(parts) target, err := catalog.Resolve(parts)
if err != nil { if err != nil {
return resolveError(err) return resolveError(err)

View File

@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version. Long: `Update lark-cli to the latest version.
Detects the installation method automatically: Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version> - npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL - manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts). Use --json for structured output (for AI agents and scripts).
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() { if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater) return doManualUpdate(opts, io, cur, latest, detect, updater)
} }
return doNpmUpdate(opts, io, cur, latest, updater) return doAutoUpdate(opts, io, cur, latest, detect, updater)
} }
// --- Output helpers --- // --- Output helpers ---
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n") fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest) if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
emitSkillsTextHints(io, skillsResult) emitSkillsTextHints(io, skillsResult)
return nil return nil
} }
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error { func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
restore, err := updater.PrepareSelfReplace() restore, err := updater.PrepareSelfReplace()
if err != nil { if err != nil {
return reportError(opts, io, "update_error", return reportError(opts, io, "update_error",
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
} }
if !opts.JSON { if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest) fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
} }
npmResult := updater.RunNpmInstall(latest) npmResult := install(latest)
if npmResult.Err != nil { if npmResult.Err != nil {
restore() restore()
combined := npmResult.CombinedOutput() combined := npmResult.CombinedOutput()
if opts.JSON { if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{ output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{ "ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err), "type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput), "detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined), "hint": permissionHint(combined, pm),
}, },
}) })
return output.ErrBare(output.ExitAPI) return output.ErrBare(output.ExitAPI)
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprint(io.ErrOut, npmResult.Stderr.String()) fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
} }
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err) fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" { if hint := permissionHint(combined, pm); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint) fmt.Fprintf(io.ErrOut, " %s\n", hint)
} }
return output.ErrBare(output.ExitAPI) return output.ErrBare(output.ExitAPI)
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
if err := updater.VerifyBinary(latest); err != nil { if err := updater.VerifyBinary(latest); err != nil {
restore() restore()
msg := fmt.Sprintf("new binary verification failed: %s", err) msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest) hint := verificationFailureHint(updater, latest, pm)
if opts.JSON { if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{ output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "ok": false,
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest) fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil { if skillsResult != nil {
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n") skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
} }
emitSkillsTextHints(io, skillsResult) emitSkillsTextHints(io, skillsResult)
return nil return nil
} }
func permissionHint(npmOutput string) string { func permissionHint(pmOutput, pm string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() { if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors" return ""
} }
return "" if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
} }
func verificationFailureHint(updater *selfupdate.Updater, latest string) string { func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
if updater.CanRestorePreviousVersion() { if updater.CanRestorePreviousVersion() {
return "the previous version has been restored" return "the previous version has been restored"
} }
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
} }

View File

@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
} }
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult { func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult { return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
} }
} }
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) { func TestNormalizeVersion(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") { if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out) t.Errorf("expected success message in stderr, got: %s", out)
} }
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
} }
func TestUpdateForce_JSON(t *testing.T) { func TestUpdateForce_JSON(t *testing.T) {
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS origOS := currentOS
defer func() { currentOS = origOS }() defer func() { currentOS = origOS }()
// Linux: EACCES should produce a hint with npm prefix guidance. // Linux + npm: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux" currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'") hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
if !strings.Contains(hint, "npm global prefix") { if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint) t.Errorf("expected npm prefix hint on linux, got: %s", hint)
} }
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint) t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
} }
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows). // Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows" currentOS = "windows"
hint = permissionHint("EACCES: permission denied") hint = permissionHint("EACCES: permission denied", "npm")
if hint != "" { if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint) t.Errorf("expected empty hint on Windows, got: %s", hint)
} }
// Non-EACCES error: always empty. // Non-EACCES error: always empty.
currentOS = "linux" currentOS = "linux"
if got := permissionHint("some other error"); got != "" { if got := permissionHint("some other error", "npm"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got) t.Errorf("expected empty hint for non-EACCES, got: %s", got)
} }
} }

View File

@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via `challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. `CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories ## Categories
| Category | When | Exit | Typed struct | | Category | When | Exit | Typed struct |

View File

@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide. // space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string { func commandFormResolver(service string) func(string) string {
byForm := map[string]string{} byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() { if svc, ok := registry.SchemaCatalog().Service(service); ok {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) { for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
} }
break
} }
return func(h string) string { return func(h string) string {
h = strings.TrimSpace(h) h = strings.TrimSpace(h)

View File

@@ -6,12 +6,10 @@ package cmdutil
import ( import (
"context" "context"
"net/http" "net/http"
"os"
"reflect" "reflect"
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync" "sync"
"unicode"
"github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
@@ -40,8 +38,6 @@ const (
BuildKindUnknown = "unknown" BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli" officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
) )
// UserAgentValue returns the User-Agent value: "lark-cli/{version}". // UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -49,25 +45,6 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version return SourceValue + "/" + build.Version
} }
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry. // BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header { func BaseSecurityHeaders() http.Header {
h := make(http.Header) h := make(http.Header)
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version) h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue()) h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" { if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v) h.Set(HeaderAgentTrace, v)
} }
return h return h

View File

@@ -6,7 +6,6 @@ package cmdutil
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/credential"
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace // HeaderAgentTrace injection (via BaseSecurityHeaders)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) { func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "") t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders() h := BaseSecurityHeaders()

View File

@@ -19,6 +19,7 @@ const (
// Content safety scanning mode // Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

36
internal/envvars/read.go Normal file
View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError. // ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta. // BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{ var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error" 1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error 1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden 1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted 1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit 1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload) 1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size 1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter 1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied 1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval 1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters" 1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed 1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field 99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors 9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
} }
func init() { mergeCodeMeta(driveCodeMeta, "drive") } func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false}, {1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false}, {1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false}, {1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, {1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true}, {2200, errs.CategoryAPI, errs.SubtypeServerError, true},
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
// command-specific recovery guidance at the shortcut layer.
var wikiCodeMeta = map[int]CodeMeta{
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
}
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }

View File

@@ -6,8 +6,7 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog" import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free) // EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for `lark-cli schema`, golden tests // metadata — deterministic across machines, for golden tests and schema lint.
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog { func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped()) return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
} }
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog { func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped()) return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
} }
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
) )
//go:embed scope_priorities.json scope_overrides.json //go:embed scope_priorities.json scope_overrides.json
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand) brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged { if !brandChanged {
if cached, err := loadCachedMerged(); err == nil { // After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
overlayMergedServices(cached) overlayMergedServices(cached)
} }
} }

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
} }
// testRegistry returns a minimal MergedRegistry with one service. // testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry { func testRegistry(name string) MergedRegistry {
return MergedRegistry{ return MergedRegistry{
Version: "test-1.0", Version: "1.0.0",
Services: []meta.Service{ Services: []meta.Service{
{ {
Name: name, Name: name,
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
} }
func TestCacheHit_WithinTTL(t *testing.T) { func TestCacheHit_WithinTTL(t *testing.T) {
resetInit() swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
} }
func TestNetworkError_SilentDegradation(t *testing.T) { func TestNetworkError_SilentDegradation(t *testing.T) {
resetInit() swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
if reg.Version != "test-1.0" { if reg.Version != "1.0.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version) t.Errorf("expected version 1.0.0, got %s", reg.Version)
} }
} }

View File

@@ -32,6 +32,7 @@ type InstallMethod int
const ( const (
InstallNpm InstallMethod = iota InstallNpm InstallMethod = iota
InstallPnpm
InstallManual InstallManual
) )
@@ -53,22 +54,32 @@ var (
// DetectResult holds installation detection results. // DetectResult holds installation detection results.
type DetectResult struct { type DetectResult struct {
Method InstallMethod Method InstallMethod
ResolvedPath string ResolvedPath string
NpmAvailable bool NpmAvailable bool
PnpmAvailable bool
} }
// CanAutoUpdate returns true if the CLI can update itself automatically. // CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool { func (d DetectResult) CanAutoUpdate() bool {
return d.Method == InstallNpm && d.NpmAvailable switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
} }
// ManualReason returns a human-readable explanation of why auto-update is unavailable. // ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string { func (d DetectResult) ManualReason() string {
if d.Method == InstallNpm && !d.NpmAvailable { switch {
case d.Method == InstallNpm && !d.NpmAvailable:
return "installed via npm, but npm is not available in PATH" return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
} }
return "not installed via npm" return "not installed via npm or pnpm"
} }
// NpmResult holds the result of an npm install or skills update execution. // NpmResult holds the result of an npm install or skills update execution.
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct { type Updater struct {
DetectOverride func() DetectResult DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error VerifyOverride func(expectedVersion string) error
@@ -101,17 +113,38 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by // running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible. // CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
} }
// New creates an Updater with default (real) behavior. // New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} } func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether // DetectInstallMethod determines how the CLI was installed and whether the
// npm is available for auto-update. // owning package manager is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult { func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil { if u.DetectOverride != nil {
return u.DetectOverride() return u.DetectOverride()
} }
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable() exe, err := vfs.Executable()
if err != nil { if err != nil {
return DetectResult{Method: InstallManual} return DetectResult{Method: InstallManual}
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
if err != nil { if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe} return DetectResult{Method: InstallManual, ResolvedPath: exe}
} }
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual method := InstallManual
if strings.Contains(resolved, "node_modules") { if strings.Contains(resolved, "node_modules") {
method = InstallNpm if containsPnpmMarker(resolved) {
} method = InstallPnpm
} else {
npmAvailable := false method = InstallNpm
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
} }
} }
d := DetectResult{Method: method, ResolvedPath: resolved}
return DetectResult{ switch method {
Method: method, case InstallNpm:
ResolvedPath: resolved, d.NpmAvailable = npmOnPath
NpmAvailable: npmAvailable, case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
} }
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
}
}
return false
} }
// RunNpmInstall executes npm install -g @larksuite/cli@<version>. // RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r return r
} }
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult { func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil { if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride() return u.SkillsIndexFetchOverride()
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...) return u.runSkillsCommand(args...)
} }
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult { func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil { if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...) return u.SkillsCommandOverride(args...)
} }
r := &NpmResult{} r := &NpmResult{}
npxPath, err := exec.LookPath("npx") det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
if err != nil { if err != nil {
r.Err = fmt.Errorf("npx not found in PATH: %w", err) r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
return r return r
} }
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, npxPath, args...) cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd.Stdout = &r.Stdout cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr cmd.Stderr = &r.Stderr
r.Err = cmd.Run() r.Err = cmd.Run()

View File

@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1]) t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
} }
} }
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}

View File

@@ -25,7 +25,7 @@ import (
const ( const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest" registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
fetchTimeout = 5 * time.Second fetchTimeout = 15 * time.Second
stateFile = "update-state.json" stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB maxBody = 256 << 10 // 256 KB

View File

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

View File

@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1 exit 1
fi fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" || if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" || ! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then ! grep -Fq "checks: write" <<<"$section"; then
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1 exit 1
fi fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output" echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1 exit 1
fi fi
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled" echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
exit 1 exit 1
fi fi

54
scripts/domain-map.js Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

71
scripts/domain-map.json Normal file
View File

@@ -0,0 +1,71 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

224
scripts/e2e_domains.js Normal file
View File

@@ -0,0 +1,224 @@
#!/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 { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,6 +4,7 @@
const fs = require("node:fs/promises"); const fs = require("node:fs/promises");
const path = require("node:path"); const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================ // ============================================================================
// Constants & Configuration // Constants & Configuration
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]); const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]); const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/; const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = { const CLASS_STANDARDS = {
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping // Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) { function getBusinessDomain(filePath) {
const normalized = normalizePath(filePath); return labelDomainsForPath(filePath)[0] || "";
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
} }
async function detectNewShortcutDomain(files) { async function detectNewShortcutDomain(files) {

View File

@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh" script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$" tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
trap 'rm -rf "$tmp"' EXIT
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
mkdir -p "$tmp" mkdir -p "$tmp"
git_init() { git_init() {

View File

@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil return attendees, nil
} }
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
return true
}
}
return false
}
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
if err == nil || !attendeesIncludeRoom(attendees) {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
return err
}
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
}
var CalendarCreate = common.Shortcut{ var CalendarCreate = common.Shortcut{
Service: "calendar", Service: "calendar",
Command: "+create", Command: "+create",
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
"need_notification": true, "need_notification": true,
}) })
if err != nil { if err != nil {
err = guideApprovalRoomReasonError(err, attendees)
// Rollback: delete the event // Rollback: delete the event
_, rollbackErr := runtime.CallAPITyped("DELETE", _, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
"start": startStr, "start": startStr,
"end": endStr, "end": endStr,
} }
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
resultData["recurrence"] = recurrence
}
runtime.OutFormat(resultData, nil, func(w io.Writer) { runtime.OutFormat(resultData, nil, func(w io.Writer) {
var rows []map[string]interface{} var rows []map[string]interface{}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +get — get a single calendar event detail by calendar_id and event_id
package calendar
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// calendarEventTime mirrors start_time / end_time in the API response.
type calendarEventTime struct {
Date string `json:"date,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// calendarEventVChat mirrors the vchat block in the API response.
type calendarEventVChat struct {
VCType string `json:"vc_type,omitempty"`
IconType string `json:"icon_type,omitempty"`
Description string `json:"description,omitempty"`
MeetingURL string `json:"meeting_url,omitempty"`
}
// calendarEventLocation mirrors the location block in the API response.
type calendarEventLocation struct {
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
}
// calendarEventReminder mirrors a reminder entry.
type calendarEventReminder struct {
Minutes int `json:"minutes"`
}
// calendarEventOrganizer mirrors event_organizer.
type calendarEventOrganizer struct {
UserID string `json:"user_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
// calendarEventAttachment mirrors a single attachment entry.
type calendarEventAttachment struct {
FileToken string `json:"file_token,omitempty"`
FileSize string `json:"file_size,omitempty"`
Name string `json:"name,omitempty"`
}
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
type calendarEventCheckInTime struct {
TimeType string `json:"time_type,omitempty"`
Duration int `json:"duration"`
}
// calendarEventCheckIn mirrors event_check_in.
type calendarEventCheckIn struct {
EnableCheckIn bool `json:"enable_check_in"`
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
NeedNotifyAttendees bool `json:"need_notify_attendees"`
}
// calendarEvent mirrors the event object inside the API response.
type calendarEvent struct {
EventID string `json:"event_id,omitempty"`
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
Visibility string `json:"visibility,omitempty"`
AttendeeAbility string `json:"attendee_ability,omitempty"`
FreeBusyStatus string `json:"free_busy_status,omitempty"`
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
Location *calendarEventLocation `json:"location,omitempty"`
Color int `json:"color,omitempty"`
Reminders []calendarEventReminder `json:"reminders,omitempty"`
Recurrence string `json:"recurrence,omitempty"`
Status string `json:"status,omitempty"`
IsException bool `json:"is_exception,omitempty"`
RecurringEventID string `json:"recurring_event_id,omitempty"`
CreateTime string `json:"create_time,omitempty"`
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
AppLink string `json:"app_link,omitempty"`
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
}
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
rawEvent, ok := data["event"]
if !ok || rawEvent == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
}
raw, err := json.Marshal(rawEvent)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
}
var event calendarEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
}
return &event, nil
}
// buildCalendarEventOutput converts the typed event into the output map and
// applies the four transformation rules:
// 1. create_time -> RFC3339
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
// 3. flatten event into the top-level result
// 4. when status != "cancelled", drop status (and adjust all-day end date)
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
raw, err := json.Marshal(event)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
}
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
}
}
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
delete(startMap, "timestamp")
}
}
}
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
delete(endMap, "timestamp")
}
}
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
if dt, _ := endMap["datetime"].(string); dt == "" {
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
}
}
}
}
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
return out, nil
}
// CalendarGet gets a single calendar event detail.
var CalendarGet = common.Shortcut{
Service: "calendar",
Command: "+get",
Description: "Get a single calendar event detail by calendar-id and event-id",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "event-id", Desc: "event ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
for _, flag := range []string{"calendar-id", "event-id"} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
if eventId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
d := common.NewDryRunAPI()
switch calendarId {
case "":
d.Desc("(calendar-id omitted) Will use primary calendar")
calendarId = "<primary>"
case "primary":
calendarId = "<primary>"
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
return d.
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
Set("calendar_id", calendarId).
Set("event_id", eventId)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarId == "" {
calendarId = PrimaryCalendarIDStr
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
validate.EncodePathSegment(calendarId),
validate.EncodePathSegment(eventId)),
nil, nil)
if err != nil {
return err
}
event, err := parseCalendarEvent(data)
if err != nil {
return err
}
out, err := buildCalendarEventOutput(event)
if err != nil {
return err
}
runtime.OutFormat(out, nil, func(w io.Writer) {
summary, _ := out["summary"].(string)
if summary == "" {
summary = "(untitled)"
}
startMap, _ := out["start_time"].(map[string]interface{})
endMap, _ := out["end_time"].(map[string]interface{})
startStr, _ := startMap["datetime"].(string)
if startStr == "" {
startStr, _ = startMap["date"].(string)
}
endStr, _ := endMap["datetime"].(string)
if endStr == "" {
endStr, _ = endMap["date"].(string)
}
eventIdOut, _ := out["event_id"].(string)
freeBusyStatus, _ := out["free_busy_status"].(string)
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
row := map[string]interface{}{
"event_id": eventIdOut,
"summary": summary,
"start": startStr,
"end": endStr,
"free_busy_status": freeBusyStatus,
"self_rsvp_status": selfRsvpStatus,
}
output.PrintTable(w, []map[string]interface{}{row})
fmt.Fprintln(w)
})
return nil
},
}

View File

@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
} }
} }
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&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_approval_room",
"summary": "Approval Room",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_approval_room/attendees",
Body: map[string]interface{}{
"code": codeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_approval_room",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Approval Room",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "omm_room1",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for approval room missing approval_reason, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for %T", err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
if p.Code != codeInvalidParamsWithDetail {
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
}
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
}
}
}
// When the add-attendees call fails AND the rollback DELETE also fails, the // When the add-attendees call fails AND the rollback DELETE also fails, the
// primary error stays the add failure (classification preserved) and the Hint // primary error stays the add failure (classification preserved) and the Hint
// must surface BOTH the rollback failure reason and the orphan event_id so the // must surface BOTH the rollback failure reason and the orphan event_id so the
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test // Shortcuts() registration test
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestShortcuts_Returns9(t *testing.T) { func TestShortcuts_Returns10(t *testing.T) {
shortcuts := Shortcuts() shortcuts := Shortcuts()
if len(shortcuts) != 9 { if len(shortcuts) != 10 {
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts)) t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
} }
names := map[string]bool{} names := map[string]bool{}
for _, s := range shortcuts { for _, s := range shortcuts {
names[s.Command] = true names[s.Command] = true
} }
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} { for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
if !names[want] { if !names[want] {
t.Errorf("missing shortcut %s", want) t.Errorf("missing shortcut %s", want)
} }
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
t.Errorf("param=%q, want --timezone", ve.Param) t.Errorf("param=%q, want --timezone", ve.Param)
} }
} }
// ---------------------------------------------------------------------------
// CalendarGet tests
// ---------------------------------------------------------------------------
func TestGet_Success_FlattensAndConvertsTimes(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_001",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Daily Sync",
"create_time": "1602504000",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
"timezone": "Asia/Shanghai",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
"timezone": "Asia/Shanghai",
},
"status": "confirmed",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_001",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Expect flattened — fields appear directly under "data", not under "data.event"
if strings.Contains(out, "\"event\": {") {
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
}
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
t.Errorf("expected event_id in output, got: %s", out)
}
// status=confirmed should be dropped
if strings.Contains(out, "\"status\": \"confirmed\"") {
t.Errorf("status should be dropped when not cancelled, got: %s", out)
}
// timestamp must be replaced with datetime
if strings.Contains(out, "\"timestamp\":") {
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
}
if !strings.Contains(out, "\"datetime\":") {
t.Errorf("expected datetime in output, got: %s", out)
}
// create_time must be RFC3339 (contain 'T' and timezone)
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
t.Errorf("expected RFC3339 create_time, got: %s", out)
}
}
func TestGet_CancelledStatus_PreservesStatus(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_002",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_002",
"summary": "Cancelled Meeting",
"create_time": "1602504000",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
"status": "cancelled",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_002",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"status\": \"cancelled\"") {
t.Errorf("status should be preserved when cancelled, got: %s", out)
}
}
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_003",
"summary": "All-day",
"start_time": map[string]interface{}{"date": "2025-03-21"},
"end_time": map[string]interface{}{"date": "2025-03-22"},
"status": "confirmed",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_003",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
}
}
func TestGet_EmptyEventID_Typed(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--event-id", " ",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("want error for empty event-id")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Param != "--event-id" {
t.Errorf("param=%q, want --event-id", ve.Param)
}
}
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_404",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("want error when event field is missing")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("want *errs.InternalError, got %T", err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
}
}

View File

@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
CalendarSuggestion, CalendarSuggestion,
CalendarMeeting, CalendarMeeting,
CalendarSearchEvent, CalendarSearchEvent,
CalendarGet,
} }
} }

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true}, {Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true}, {Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"}, {Name: "doc-id", Desc: "document ID (for drive_route_token)"},
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md" return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update": case "update":
return docsSkillReadCommand + " references/lark-doc-update.md" return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default: default:
return docsSkillReadCommand return docsSkillReadCommand
} }
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help" return "lark-cli docs +fetch --help"
case "update": case "update":
return "lark-cli docs +update --help" return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default: default:
return "lark-cli docs --help" return "lark-cli docs --help"
} }
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
DocsCreate, DocsCreate,
DocsFetch, DocsFetch,
DocsUpdate, DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert, DocMediaInsert,
DocMediaUpload, DocMediaUpload,
DocMediaPreview, DocMediaPreview,

View File

@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -28,6 +29,8 @@ const (
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024 driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024 driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024 driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
) )
// driveImportExtToDocTypes defines which source file extensions can be imported // driveImportExtToDocTypes defines which source file extensions can be imported
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
"pptx": {"slides"}, "pptx": {"slides"},
} }
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
// driveImportSpec contains the user-facing import inputs after normalization. // driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct { type driveImportSpec struct {
FilePath string FilePath string
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return status, true, nil return status, true, nil
} }
if status.Failed() { if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg) return status, false, driveImportFailureError(status)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
} }
} }
if !hadSuccessfulPoll && lastErr != nil { if !hadSuccessfulPoll && lastErr != nil {
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return lastStatus, false, nil return lastStatus, false, nil
} }
func driveImportFailureError(status driveImportStatus) *errs.APIError {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
if code, ok := driveImportConcurrentOperationCode(msg); ok {
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
}
return apiErr
}
func driveImportConcurrentOperationCode(msg string) (int, bool) {
for _, code := range driveImportConcurrentOperationCodes {
codeText := strconv.Itoa(code)
for idx := strings.Index(msg, codeText); idx >= 0; {
end := idx + len(codeText)
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
return code, true
}
nextStart := idx + 1
next := strings.Index(msg[nextStart:], codeText)
if next < 0 {
break
}
idx = nextStart + next
}
}
return 0, false
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}

View File

@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
} }
} }
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
t.Parallel()
for _, code := range driveImportConcurrentOperationCodes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype != errs.SubtypeServerError {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
}
if problem.Code != code {
t.Fatalf("code = %d, want %d", problem.Code, code)
}
if !problem.Retryable {
t.Fatal("expected retryable error")
}
if problem.Hint != driveImportConcurrentOperationHint {
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
}
})
}
}
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg string
}{
{
name: "ordinary failure",
msg: "unsupported conversion",
},
{
name: "longer numeric code containing known code",
msg: "call CreateObjNode return error code, code: 12321401012, message:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: tt.msg,
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Code != 0 {
t.Fatalf("code = %d, want 0", problem.Code)
}
if problem.Retryable {
t.Fatal("expected non-concurrency failure to remain non-retryable")
}
if problem.Hint != "" {
t.Fatalf("hint = %q, want empty", problem.Hint)
}
})
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{

View File

@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
var downloaded, skipped, failed, deletedLocal int var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0 downloadFailed := 0
aborted := false
items := make([]drivePullItem, 0) items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability. // Deterministic iteration order for output stability.
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths) sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths { for _, rel := range downloadablePaths {
if drivePullHasTerminalFailure(items) { if aborted {
break break
} }
targetFile := remoteFiles[rel] targetFile := remoteFiles[rel]
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
failed++ failed++
downloadFailed++ downloadFailed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
break break
} }
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
"skipped": skipped, "skipped": skipped,
"failed": failed, "failed": failed,
"deleted_local": deletedLocal, "deleted_local": deletedLocal,
"aborted": drivePullHasTerminalFailure(items), "aborted": aborted,
}, },
"items": items, "items": items,
} }
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
return item, decision.Terminal return item, decision.Terminal
} }
func drivePullHasTerminalFailure(items []drivePullItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// drivePullDownload streams one Drive file into the local mirror target and // drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time. // then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error { func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {

View File

@@ -35,6 +35,7 @@ type drivePushItem struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"` ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"` Code int `json:"code,omitempty"`
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
Subtype string Subtype string
Retryable bool Retryable bool
Terminal bool Terminal bool
Hint string
} }
// DrivePush is a one-way, file-level mirror from a local directory onto a // DrivePush is a one-way, file-level mirror from a local directory onto a
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
// locally and now on Drive too), which is the worst-of-both-worlds // locally and now on Drive too), which is the worst-of-both-worlds
// outcome the review flagged. // outcome the review flagged.
uploadFailed := false uploadFailed := false
aborted := false
// folderCache holds rel_path → folder_token. Seeded from the remote // folderCache holds rel_path → folder_token. Seeded from the remote
// listing (so we don't recreate folders that already exist) and // listing (so we don't recreate folders that already exist) and
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break break
} }
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
for _, rel := range localPaths { for _, rel := range localPaths {
localFile := localFiles[rel] localFile := localFiles[rel]
if uploadFailed && drivePushHasTerminalFailure(items) { if uploadFailed && aborted {
break break
} }
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
break break
} }
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break break
} }
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
continue continue
} }
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
if drivePushIsAlreadyDeleted(err) {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
continue
}
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err) item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
abortDelete = true abortDelete = true
break break
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
"skipped": skipped, "skipped": skipped,
"failed": failed, "failed": failed,
"deleted_remote": deletedRemote, "deleted_remote": deletedRemote,
"aborted": drivePushHasTerminalFailure(items), "aborted": aborted,
}, },
"items": items, "items": items,
} }
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
Action: action, Action: action,
SizeBytes: sizeBytes, SizeBytes: sizeBytes,
Error: err.Error(), Error: err.Error(),
Hint: decision.Hint,
Phase: phase, Phase: phase,
ErrorClass: decision.Class, ErrorClass: decision.Class,
Code: decision.Code, Code: decision.Code,
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
decision.Class = "file_size_limit" decision.Class = "file_size_limit"
case problem.Code == 1062009: case problem.Code == 1062009:
decision.Class = "upload_size_mismatch" decision.Class = "upload_size_mismatch"
case problem.Code == 1061044:
decision.Class = "parent_node_missing"
decision.Terminal = true
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007: case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
decision.Class = "remote_not_found" decision.Class = "remote_not_found"
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200: case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
return decision return decision
} }
func drivePushHasTerminalFailure(items []drivePushItem) bool { func drivePushIsAlreadyDeleted(err error) bool {
for _, item := range items { problem, ok := errs.ProblemOf(err)
if driveTerminalBatchErrorClass(item.ErrorClass) { return ok && problem.Code == 1061007
return true
}
}
return false
}
func driveTerminalBatchErrorClass(errorClass string) bool {
switch errorClass {
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
return true
default:
return false
}
} }
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) { func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {

View File

@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
} }
} }
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/tok_orphan",
Body: map[string]interface{}{
"code": 1061007,
"msg": "file has been delete.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(0) {
t.Fatalf("summary.failed = %v, want 0", got)
}
if got := summary["deleted_remote"]; got != float64(0) {
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
t.Fatalf("unexpected already-deleted item: %#v", item)
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) { func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
} }
} }
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
t.Fatalf("WriteFile a: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
t.Fatalf("WriteFile b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 1061044,
"msg": "parent node not exist.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b.txt" {
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
}
}
}
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) { func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"}, {Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"}, {Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"}, {Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
// --- Phase 2: Execute sync operations --- // --- Phase 2: Execute sync operations ---
var pulled, pushed, skipped, failed int var pulled, pushed, skipped, failed int
aborted := false
items := make([]driveSyncItem, 0) items := make([]driveSyncItem, 0)
// Build push infrastructure: local walk for push + remote views + folder cache. // Build push infrastructure: local walk for push + remote views + folder cache.
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
// Mirror local directory structure first (same as +push), so // Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped. // empty local directories are not silently dropped.
for _, relDir := range localDirs { for _, relDir := range localDirs {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
if _, alreadyRemote := folderCache[relDir]; alreadyRemote { if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue continue
} }
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr) item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue continue
} }
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"}) items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
// 2a. Pull new_remote files. // 2a. Pull new_remote files.
for _, entry := range newRemote { for _, entry := range newRemote {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
targetFile, ok := pullRemoteFiles[entry.RelPath] targetFile, ok := pullRemoteFiles[entry.RelPath]
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break break
} }
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
// 2b. Push new_local files. // 2b. Push new_local files.
for _, entry := range newLocal { for _, entry := range newLocal {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
localFile, ok := pushLocalFiles[entry.RelPath] localFile, ok := pushLocalFiles[entry.RelPath]
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
parentRel := drivePushParentRel(entry.RelPath) parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil { if ensureErr != nil {
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr) item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue continue
} }
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
// 2c. Resolve modified files by --on-conflict strategy. // 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified { for _, entry := range modified {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
remoteFile := remoteFiles[entry.RelPath] remoteFile := remoteFiles[entry.RelPath]
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break break
} }
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
} }
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache) parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil { if parentErr != nil {
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr) item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
break
}
continue continue
} }
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken) token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
break break
} }
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
"pushed": pushed, "pushed": pushed,
"skipped": skipped, "skipped": skipped,
"failed": failed, "failed": failed,
"aborted": driveSyncHasTerminalFailure(items), "aborted": aborted,
}, },
"items": items, "items": items,
} }
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
return item, decision.Terminal return item, decision.Terminal
} }
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy // driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the // for a single file. Returns the strategy string, or empty string if the
// user chose to skip. // user chose to skip.

View File

@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
// original message as read after a reply/reply-all/forward operation. // original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) { func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut, fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+ "tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n", shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
} }
// hintReadReceiptRequest prints a stderr tip when a message that the caller // hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read. // TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) { func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t) rt, _, stderr := newOutputRuntime(t)
// Inject ANSI escape + message ID to verify sanitization hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
out := stderr.String() out := stderr.String()
if strings.Contains(out, "\x1b[") { if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out) t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
} }
if !strings.Contains(out, "msg-123") { if strings.Contains(out, "\nnext") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out) t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
}
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
}
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
} }
} }

View File

@@ -0,0 +1,482 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func messageManageID(suffix string) string {
return "msg_abcdefghijklmnop_" + suffix
}
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_message_ids"].([]interface{})
if !ok {
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
}
failed, ok := data["failed_message_ids"].([]interface{})
if !ok {
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
}
return success, failed
}
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected validation error for %s, got nil", param)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem for %s, got %T", param, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr.Param != param {
t.Fatalf("param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected failed precondition error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem, got %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
}
}
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
}
cases := [][]string{
{""},
{" id_with_leading_space_12345"},
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
{"1234567890123456"},
{"short"},
{"msg_abcdefghijklmnop!"},
{"msg_abcdefghijklmnop\t"},
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
}
for _, tc := range cases {
_, err := normalizeMessageManageIDs(tc)
requireMessageManageValidationParam(t, err, "--message-ids")
}
}
func TestMessageModify_Metadata(t *testing.T) {
if MailMessageModify.Command != "+message-modify" {
t.Fatalf("Command = %q", MailMessageModify.Command)
}
if MailMessageModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
}
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
}
requiredScopes := map[string]bool{
"mail:user_mailbox.message:modify": true,
}
for _, scope := range MailMessageModify.Scopes {
delete(requiredScopes, scope)
}
if len(requiredScopes) != 0 {
t.Errorf("Scopes missing %v", requiredScopes)
}
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailMessageModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
}
}
func TestMessageTrash_Metadata(t *testing.T) {
if MailMessageTrash.Command != "+message-trash" {
t.Fatalf("Command = %q", MailMessageTrash.Command)
}
if MailMessageTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
}
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
}
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
}
}
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
token := auth.GetStoredToken("test-app", "ou_testuser")
if token == nil {
t.Fatal("expected test token")
}
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "UNREAD",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
}
}
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "read_receipt_request",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
}
}
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "folderA",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "folderA" {
t.Errorf("add_folder = %v, want folderA", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if removeLabels[0] != "FLAGGED" {
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 1 || success[0] != id || len(failed) != 0 {
t.Errorf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := messageManageID("1")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
t.Fatalf("error = %v, want label intersection validation", err)
}
err = runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-folder")
if !strings.Contains(err.Error(), "use +message-trash") {
t.Fatalf("error = %v, want TRASH validation", err)
}
}
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2 + "," + id1,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 41)
for i := range ids {
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
}
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
messageIDs := body["message_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(messageIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/messages/batch_modify`,
`validation_api_plan`,
`/user_mailboxes/me/labels/customA`,
`/user_mailboxes/me/folders/folderA`,
`will_validate`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["message_ids"].([]interface{})); got != 2 {
t.Fatalf("message_ids len = %d, want 2", got)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "trash newline in repeated flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
},
{
name: "trash tab in csv flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
},
{
name: "modify space in repeated flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
},
{
name: "modify space in csv flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
}
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
t.Fatalf("error = %v, want whitespace/control validation", err)
}
})
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
type messageModifyInput struct {
MessageIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
AddFolder string
CustomLabelIDs []string
CustomFolderID string
ValidationAPIPlans []validationAPIPlan
}
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
// state labels, or a folder move to existing messages in batches of 20.
var MailMessageModify = common.Shortcut{
Service: "mail",
Command: "+message-modify",
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
ConditionalScopes: []string{
"mail:user_mailbox.folder:read",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
},
Validate: validateMessageModify,
DryRun: dryRunMessageModify,
Execute: executeMessageModify,
}
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
_, err := buildMessageModifyInput(rt)
return err
}
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildMessageModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
Set("validation_api_plan", input.ValidationAPIPlans)
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
}
return api
}
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildMessageModifyInput(rt)
if err != nil {
return err
}
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
return err
}
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
return err
}
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
emitMessageManageSummary(rt, messageManageSummary{
SuccessMessageIDs: input.MessageIDs,
FailedMessageIDs: []messageManageFailure{},
}, true)
return nil
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message modify batches failed")
}
return nil
}
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return messageModifyInput{}, err
}
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return messageModifyInput{}, err
}
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return messageModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return messageModifyInput{}, err
}
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
if err != nil {
return messageModifyInput{}, err
}
customLabels := append(customAddLabels, customRemoveLabels...)
customFolderID := ""
if customFolder {
customFolderID = folder
}
return messageModifyInput{
MessageIDs: messageIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
AddFolder: folder,
CustomLabelIDs: customLabels,
CustomFolderID: customFolderID,
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
}, nil
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailMessageTrash = common.Shortcut{
Service: "mail",
Command: "+message-trash",
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateMessageTrash,
DryRun: dryRunMessageTrash,
Execute: executeMessageTrash,
}
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
return err
}
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete messages sequentially in batches of 20").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(messageIDs))
for _, batch := range chunkMessageManageIDs(messageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
Body(map[string]interface{}{"message_ids": batch})
}
return api
}
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return err
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(messageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
map[string]interface{}{"message_ids": batch})
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message trash batches failed")
}
return nil
}

View File

@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(), ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(), RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly", Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(), GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
} }
if err := auth.SetStoredToken(token); err != nil { if err := auth.SetStoredToken(token); err != nil {

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"io"
"strings"
"unicode"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const mailMessageManageBatchSize = 20
var messageManageSystemLabels = map[string]string{
"UNREAD": "UNREAD",
"IMPORTANT": "IMPORTANT",
"OTHER": "OTHER",
"FLAGGED": "FLAGGED",
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
}
var messageManageSystemFolders = map[string]string{
"INBOX": "INBOX",
"SENT": "SENT",
"SPAM": "SPAM",
"ARCHIVE": "ARCHIVED",
"ARCHIVED": "ARCHIVED",
}
type messageManageSummary struct {
SuccessMessageIDs []string `json:"success_message_ids"`
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
}
type messageManageFailure struct {
MessageID string `json:"message_id"`
Reason string `json:"reason"`
}
type validationAPIPlan struct {
Method string `json:"method"`
Path string `json:"path"`
WillValidate bool `json:"will_validate"`
}
func normalizeMessageManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
parts, err := splitMessageManageIDTokens(raw)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
if part == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if id != part {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateMessageManageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
return ids, nil
}
func splitMessageManageIDTokens(raw []string) ([]string, error) {
parts := make([]string, 0, len(raw))
for i, token := range raw {
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
}
}
parts = append(parts, strings.Split(token, ",")...)
}
return parts, nil
}
func validateMessageManageID(id string, index int) error {
if len(id) < 16 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
}
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
}
for _, r := range id {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
}
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
}
}
return nil
}
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
labels := make([]string, 0, len(raw))
custom := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
normalized := id
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
normalized = system
} else {
custom = append(custom, id)
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, custom, nil
}
func validateLabelIntersection(add, remove []string) error {
removeSet := make(map[string]struct{}, len(remove))
for _, id := range remove {
removeSet[id] = struct{}{}
}
for _, id := range add {
if _, ok := removeSet[id]; ok {
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
}
}
return nil
}
func normalizeMessageManageFolder(raw string) (string, bool, error) {
if raw == "" {
return "", false, nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
}
if folder != raw {
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
}
if strings.EqualFold(folder, "TRASH") {
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, false, nil
}
return folder, true, nil
}
func chunkMessageManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
end := start + mailMessageManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := validateLabelReadScope(rt); err != nil {
return err
}
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "label not found: %s", id)
}
}
return nil
}
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
if id == "" {
return nil
}
if err := validateFolderReadScope(rt); err != nil {
return err
}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "folder not found: %s", id)
}
return nil
}
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
body := map[string]interface{}{"message_ids": ids}
if len(addLabels) > 0 {
body["add_label_ids"] = addLabels
}
if len(removeLabels) > 0 {
body["remove_label_ids"] = removeLabels
}
if addFolder != "" {
body["add_folder"] = addFolder
}
return body
}
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
seenLabels := map[string]struct{}{}
for _, id := range customLabels {
if _, ok := seenLabels[id]; ok {
continue
}
seenLabels[id] = struct{}{}
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "labels", id),
WillValidate: true,
})
}
if customFolder != "" {
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "folders", customFolder),
WillValidate: true,
})
}
return plans
}
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
if noAPICalls {
fmt.Fprintln(w, "No changes requested; no API calls were made.")
}
for _, item := range summary.FailedMessageIDs {
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
}
})
}

View File

@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{ return []common.Shortcut{
MailMessage, MailMessage,
MailMessages, MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread, MailThread,
MailTriage, MailTriage,
MailWatch, MailWatch,

View File

@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
case 90003087: case 90003087:
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.") appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
case 1061003, 1061044: case 1061003, 1061044:
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.") appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
case 1061004, 1062501: case 1061004, 1062501:
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.") appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
case 1061101:
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
case 233523001:
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
case 99991400:
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
} }
} }
return err return err

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
Tips: []string{ Tips: []string{
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.", "Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.", "Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{ spec, err := readMarkdownCreateSpec(runtime)
FileName: strings.TrimSpace(runtime.Str("name")), if err != nil {
FolderToken: strings.TrimSpace(runtime.Str("folder-token")), return err
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")), }
FilePath: strings.TrimSpace(runtime.Str("file")), return validateMarkdownSpec(runtime, spec, true)
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, true)
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{ spec, err := readMarkdownCreateSpec(runtime)
FileName: strings.TrimSpace(runtime.Str("name")), if err != nil {
FolderToken: strings.TrimSpace(runtime.Str("folder-token")), return common.NewDryRunAPI().Set("error", err.Error())
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
} }
fileSize, err := markdownSourceSize(runtime, spec) fileSize, err := markdownSourceSize(runtime, spec)
if err != nil { if err != nil {
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
return dry return dry
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{ spec, err := readMarkdownCreateSpec(runtime)
FileName: strings.TrimSpace(runtime.Str("name")), if err != nil {
FolderToken: strings.TrimSpace(runtime.Str("folder-token")), return err
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
} }
fileSize, err := markdownSourceSize(runtime, spec) fileSize, err := markdownSourceSize(runtime, spec)
if err != nil { if err != nil {
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
return nil return nil
}, },
} }
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
return normalizeMarkdownCreateTargetSpec(spec)
}
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
if spec.FolderToken != "" {
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
if err != nil {
return markdownUploadSpec{}, err
}
spec.FolderToken = token
}
if spec.WikiToken != "" {
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
if err != nil {
return markdownUploadSpec{}, err
}
spec.WikiToken = token
}
return spec, nil
}
func normalizeMarkdownFolderToken(token string) (string, error) {
token = strings.TrimSpace(token)
if strings.Contains(token, "://") {
ref, ok := common.ParseResourceURL(token)
if !ok {
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
WithHint("Pass a Drive folder URL or raw folder token.")
}
if ref.Type != "folder" {
return "", markdownValidationParamError("--folder-token",
"--folder-token must identify a Drive folder; got a %s URL",
ref.Type,
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
}
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
return "", err
}
return ref.Token, nil
}
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
return "", err
}
switch markdownKnownResourceTokenKind(token) {
case "wiki":
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
WithHint("Pass it with --wiki-token instead.")
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
}
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
return "", err
}
return token, nil
}
func normalizeMarkdownWikiToken(token string) (string, error) {
token = strings.TrimSpace(token)
if strings.Contains(token, "://") {
ref, ok := common.ParseResourceURL(token)
if !ok {
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
WithHint("Pass a wiki node URL or raw wiki node token.")
}
if ref.Type != "wiki" {
return "", markdownValidationParamError("--wiki-token",
"--wiki-token must identify a wiki node; got a %s URL",
ref.Type,
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
}
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
return "", err
}
return ref.Token, nil
}
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
return "", err
}
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
}
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
return "", err
}
return token, nil
}
func rejectMarkdownPartialToken(token, flagName string) error {
if strings.ContainsAny(token, "/?#") {
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
}
return nil
}
func validateMarkdownTargetTokenName(token, flagName string) error {
if err := validate.ResourceName(token, flagName); err != nil {
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
}
return nil
}
func markdownKnownResourceTokenKind(token string) string {
lower := strings.ToLower(strings.TrimSpace(token))
switch {
case strings.HasPrefix(lower, "wik"):
return "wiki"
case strings.HasPrefix(lower, "docx"):
return "docx"
case strings.HasPrefix(lower, "doc"):
return "doc"
case strings.HasPrefix(lower, "sht"):
return "sheet"
case strings.HasPrefix(lower, "bas"):
return "bitable"
case strings.HasPrefix(lower, "mn"):
return "mindnote"
case strings.HasPrefix(lower, "sld"):
return "slides"
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
return "file"
default:
return ""
}
}

View File

@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
} }
} }
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"parent_type": "explorer"`) {
t.Fatalf("dry-run missing explorer parent_type: %s", out)
}
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
}
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
}
}
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
}, f, stdout)
if err == nil {
t.Fatalf("expected folder-token URL type error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
t.Fatalf("expected folder-token URL type error, got %v", err)
}
}
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
}, f, stdout)
if err == nil {
t.Fatalf("expected wiki-token URL type error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
t.Fatalf("expected wiki-token URL type error, got %v", err)
}
}
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
run func() (string, error)
wantMsg string
wantHint string
}{
{
name: "wiki token passed as folder token",
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
wantMsg: "--folder-token looks like a wiki node token",
wantHint: "--wiki-token",
},
{
name: "folder token path fragment",
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
wantMsg: "--folder-token must be a raw token",
wantHint: "full Lark URL",
},
{
name: "doc token passed as wiki token",
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
wantMsg: "--wiki-token must be a wiki node token",
wantHint: "",
},
{
name: "wiki token query fragment",
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
wantMsg: "--wiki-token must be a raw token",
wantHint: "path/query/fragment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.run()
if err == nil {
t.Fatalf("expected validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantMsg) {
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
}
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
}
})
}
}
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
t.Parallel()
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
if err != nil {
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
}
if folderToken != "folder_token_raw" {
t.Fatalf("folder token = %q", folderToken)
}
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
if err != nil {
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
}
if wikiToken != "wik_placeholder_raw" {
t.Fatalf("wiki token = %q", wikiToken)
}
}
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
t.Parallel()
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf(quotaErr) ok=false")
}
if !strings.Contains(p.Hint, "storage quota is exhausted") {
t.Fatalf("quota hint = %q", p.Hint)
}
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
p, ok = errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf(serverErr) ok=false")
}
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
}
}
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) { func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())

View File

@@ -28,7 +28,12 @@ import (
const minutesDetailLogPrefix = "[minutes +detail]" const minutesDetailLogPrefix = "[minutes +detail]"
// Error codes from the minutes API. // Error codes from the minutes API.
const minutesDetailNoReadPermissionCode = 2091005 const (
minutesDetailProcessingCode = 2091003
minutesDetailNoReadPermissionCode = 2091005
minutesDetailWaitTimeoutDefault = 300
minutesDetailWaitIntervalDefault = 15
)
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`) var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
// minuteDetailItem represents a single minute detail result. // minuteDetailItem represents a single minute detail result.
type minuteDetailItem struct { type minuteDetailItem struct {
MinuteToken string `json:"minute_token"` MinuteToken string `json:"minute_token"`
Status string `json:"status,omitempty"`
Title string `json:"title"` Title string `json:"title"`
NoteID string `json:"note_id"` NoteID string `json:"note_id"`
Artifacts map[string]any `json:"artifacts,omitempty"` Artifacts map[string]any `json:"artifacts,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
NextCommand string `json:"next_command,omitempty"`
} }
// fetchMinuteDetail queries a single minute's metadata and selected artifacts. // fetchMinuteDetail queries a single minute's metadata and selected artifacts.
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem { func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
data, err := runtime.CallAPITyped(http.MethodGet, artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil) waitReady := runtime.Bool("wait-ready")
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
return runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
})
if err != nil { if err != nil {
result := &minuteDetailItem{MinuteToken: minuteToken} result := &minuteDetailItem{MinuteToken: minuteToken}
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode { if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken) result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
} else { } else {
result.Error = fmt.Sprintf("failed to query minute: %v", err) result.Error = fmt.Sprintf("failed to query minute: %v", err)
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
needKeyword := runtime.Bool("keyword") needKeyword := runtime.Bool("keyword")
if needSummary || needTodo || needChapter || needTranscript || needKeyword { if needSummary || needTodo || needChapter || needTranscript || needKeyword {
artData, err := runtime.CallAPITyped(http.MethodGet, artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil) return runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
})
if err != nil { if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err) if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
} else {
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
}
} else { } else {
artifacts := make(map[string]any) artifacts := make(map[string]any)
if needSummary { if needSummary {
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
return result return result
} }
func isMinutesDetailProcessingError(err error) bool {
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
return true
}
return false
}
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
}
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
if timeoutSeconds <= 0 {
timeoutSeconds = minutesDetailWaitTimeoutDefault
}
if intervalSeconds <= 0 {
intervalSeconds = minutesDetailWaitIntervalDefault
}
return timeoutSeconds, intervalSeconds
}
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
deadline := time.Now().Add(timeout)
for {
data, err := call()
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
return data, err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
remaining := time.Until(deadline)
if remaining <= 0 || interval > remaining {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
var flags []string
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
if runtime.Bool(flag) {
flags = append(flags, "--"+flag)
}
}
return flags
}
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
result.Status = "processing"
result.Retryable = true
result.Error = reason
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
}
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
parts = append(parts, artifactFlags...)
parts = append(parts, "--wait-ready")
return strings.Join(parts, " ")
}
// saveDetailTranscript persists transcript bytes to the canonical artifact path. // saveDetailTranscript persists transcript bytes to the canonical artifact path.
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/ // With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default // to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
{Name: "keyword", Type: "bool", Desc: "include keywords"}, {Name: "keyword", Type: "bool", Desc: "include keywords"},
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"}, {Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens")) tokens := common.SplitCSV(runtime.Str("minute-tokens"))
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
for _, r := range results { for _, r := range results {
row := map[string]interface{}{"minute_token": r.MinuteToken} row := map[string]interface{}{"minute_token": r.MinuteToken}
if r.Error != "" { if r.Error != "" {
row["status"] = "FAIL" if r.Status == "processing" {
row["status"] = "PROCESSING"
} else {
row["status"] = "FAIL"
}
row["error"] = r.Error row["error"] = r.Error
if r.NextCommand != "" {
row["next_command"] = r.NextCommand
}
} else { } else {
row["status"] = "OK" row["status"] = "OK"
row["title"] = r.Title row["title"] = r.Title

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
} }
} }
func detailProcessingStub(path string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: path,
Body: map[string]interface{}{
"code": 2091003,
"msg": "minute is processing",
},
}
}
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) { func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil) err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
} }
} }
func TestDetail_HiddenWaitFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesDetail.Mount(parent, f)
parent.SetOut(stdout)
parent.SetArgs([]string{"+detail", "--help"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("help failed: %v", err)
}
help := stdout.String()
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
if strings.Contains(help, hidden) {
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
}
}
stdout.Reset()
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("hidden wait flags should parse: %v", err)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Execute tests with mocked HTTP // Execute tests with mocked HTTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
} }
} }
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" {
t.Fatalf("status = %v, want processing", m["status"])
}
if m["retryable"] != true {
t.Fatalf("retryable = %v, want true", m["retryable"])
}
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
t.Fatalf("next_command = %v", m["next_command"])
}
}
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" {
t.Fatalf("status = %v, want processing", m["status"])
}
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
}
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
t.Fatalf("next_command = %v", m["next_command"])
}
}
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := firstDetailMinute(t, stdout.Bytes())
if m["title"] != "Ready Metadata" {
t.Fatalf("title = %v, want Ready Metadata", m["title"])
}
if _, ok := m["artifacts"]; ok {
t.Fatal("artifacts should not be fetched without artifact flags")
}
}
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
reg.Register(detailArtifactsStub("tokwaitart", ""))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := firstDetailMinute(t, stdout.Bytes())
arts, _ := m["artifacts"].(map[string]any)
if arts == nil {
t.Fatal("expected artifacts")
}
if arts["summary"] != "Test summary content" {
t.Fatalf("summary = %v", arts["summary"])
}
}
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
}
}
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var callCount int
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tokmissing",
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
Reusable: true,
OnMatch: func(req *http.Request) { callCount++ },
})
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
if callCount != 1 {
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pure function tests // Pure function tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
} }
} }
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
}
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
}
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
if timeout != 9 || interval != 3 {
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
}
}
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
t.Helper()
var resp map[string]any
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
}
m, _ := minutes[0].(map[string]any)
return m
}
// chdirForDetailTest switches cwd to a temp dir for the test. // chdirForDetailTest switches cwd to a temp dir for the test.
func chdirForDetailTest(t *testing.T) string { func chdirForDetailTest(t *testing.T) string {
t.Helper() t.Helper()

View File

@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token")) minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
dr := common.NewDryRunAPI() return common.NewDryRunAPI().
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" { PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime)) Body(buildSpeakerReplaceRequestBody(runtime))
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token")) minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id")) fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken) _, err := runtime.CallAPITyped(http.MethodPut,
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)) map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil { if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID)) return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
} }
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil return nil
}, },
} }
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body return body
} }
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{ out := map[string]interface{}{
"minute_token": minuteToken, "minute_token": minuteToken,
"to_user_id": toUserID, "to_user_id": toUserID,
} }
if fromSpeakerID != "" { if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else { } else {
out["from_user_id"] = fromUserID out["from_user_id"] = fromUserID
} }
return out return out
} }
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string { func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
if fromSpeakerID != "" { if fromSpeakerID != "" {
return fromSpeakerID return fromSpeakerID
} }

View File

@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
} }
} }
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) { func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t) warmTokenCache(t)
reg.Register(&httpmock.Stub{ // Only the PUT is registered on purpose: an opaque speaker_id must be passed
Method: http.MethodGet, // straight through without a second speakerlist call. If the code still
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist", // prefetched speakerlist, the unregistered GET would fail the request.
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: http.MethodPut, Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{ err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace", "+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken, "--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1", "--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--to-user-id", "ou_new_speaker", "--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user", "--format", "json", "--as", "user",
}, f, stdout) }, f, stdout)
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
var envelope struct { var envelope struct {
Data struct { Data struct {
MinuteToken string `json:"minute_token"` FromSpeakerID string `json:"from_speaker_id"`
FromSpeakerInput string `json:"from_speaker_input"` ToUserID string `json:"to_user_id"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err) t.Fatalf("unmarshal stdout: %v", err)
} }
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" { if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID) t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
} }
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
} }
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
} }
out := stdout.String() out := stdout.String()
if !strings.Contains(out, "GET") { if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out) t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
} }
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") { if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out) t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

@@ -5,6 +5,8 @@ package minutes
import ( import (
"context" "context"
"net/url"
"strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
outData := map[string]interface{}{ outData := map[string]interface{}{
"minute_url": minuteURL, "minute_url": minuteURL,
} }
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
outData["minute_token"] = minuteToken
}
runtime.OutFormat(outData, nil, nil) runtime.OutFormat(outData, nil, nil)
return nil return nil
}, },
} }
func extractUploadedMinuteToken(minuteURL string) string {
u, err := url.Parse(minuteURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
for i, part := range parts {
if part == "minutes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

View File

@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" { if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"]) t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
} }
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
}
}
func TestExtractUploadedMinuteToken(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
{name: "invalid", url: "://bad", want: ""},
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
} }

View File

@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
} }
} }
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) { func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"} program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t)) RegisterShortcuts(program, newRegisterTestFactory(t))

View File

@@ -6,6 +6,7 @@ package wiki
import ( import (
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -26,3 +27,17 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
} }
return common.BuildResourceURL(brand, "wiki", node.NodeToken) return common.BuildResourceURL(brand, "wiki", node.NodeToken)
} }
func appendWikiProblemHint(err error, hint string) error {
if strings.TrimSpace(hint) == "" {
return err
}
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
}
return err
}

View File

@@ -5,12 +5,14 @@ package wiki
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/url" "net/url"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
@@ -130,6 +132,147 @@ func TestWikiNodeListRequiresSpaceID(t *testing.T) {
} }
} }
func TestWikiNodeListRejectsNonNumericSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "wikcnABC", "--as", "user",
}, factory, nil)
if err == nil {
t.Fatalf("expected numeric space_id validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--space-id" {
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--space-id", p, validationErr.Param)
}
if !strings.Contains(p.Message, "--space-id must be a numeric wiki space_id") || !strings.Contains(p.Hint, "+space-list") {
t.Fatalf("expected numeric space_id validation error, got %v", err)
}
}
func TestWikiNodeListRejectsDocumentURLAsParentNodeToken(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list",
"--space-id", "7211568716812369922",
"--parent-node-token", "https://feishu.cn/docx/docxABC",
"--as", "user",
}, factory, nil)
if err == nil {
t.Fatalf("expected parent-node-token URL type validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--parent-node-token" {
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--parent-node-token", p, validationErr.Param)
}
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
t.Fatalf("expected parent-node-token URL type validation error, got %v", err)
}
}
func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
t.Parallel()
token, err := normalizeWikiNodeListParentToken("https://feishu.cn/wiki/wikcnPARENT?from=copy")
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
}
if token != "wikcnPARENT" {
t.Fatalf("token = %q, want wikcnPARENT", token)
}
}
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
t.Parallel()
if err := validateWikiNodeListSpaceID("https://example.invalid/wiki/space"); err == nil {
t.Fatalf("expected URL space-id validation error")
} else {
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "not a URL or path") || !strings.Contains(p.Hint, "+space-list") {
t.Fatalf("problem = %#v, want URL/path message and +space-list hint", p)
}
}
tests := []struct {
name string
input string
wantMsg string
}{
{
name: "partial wiki path",
input: "wik_placeholder/child",
wantMsg: "raw wiki node token",
},
{
name: "document token",
input: "docx_placeholder_parent",
wantMsg: "must be a wiki node token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := normalizeWikiNodeListParentToken(tt.input)
if err == nil {
t.Fatalf("expected parent token validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantMsg) {
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
}
})
}
}
func TestWikiNodeListAcceptsEmptyParentToken(t *testing.T) {
t.Parallel()
token, err := normalizeWikiNodeListParentToken("")
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken(empty) error = %v", err)
}
if token != "" {
t.Fatalf("token = %q, want empty", token)
}
}
func TestWikiNodeListProblemAddsActionableHint(t *testing.T) {
t.Parallel()
err := errs.NewAPIError(errs.SubtypeInvalidParameters, "param err: invalid page_token").WithCode(131002)
got := wikiNodeListProblem(err, nil)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf() ok=false")
}
if !strings.Contains(p.Hint, "page token is invalid or stale") {
t.Fatalf("hint = %q, want invalid page token guidance", p.Hint)
}
}
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) { func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -137,14 +280,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes", URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
Body: map[string]interface{}{ Body: map[string]interface{}{
"code": 0, "code": 0,
"data": map[string]interface{}{ "data": map[string]interface{}{
"has_more": false, "has_more": false,
"items": []interface{}{ "items": []interface{}{
map[string]interface{}{ map[string]interface{}{
"space_id": "space_123", "space_id": "7211568716812369922",
"node_token": "wik_node_1", "node_token": "wik_node_1",
"obj_token": "docx_1", "obj_token": "docx_1",
"obj_type": "docx", "obj_type": "docx",
@@ -154,7 +297,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
"has_child": true, "has_child": true,
}, },
map[string]interface{}{ map[string]interface{}{
"space_id": "space_123", "space_id": "7211568716812369922",
"node_token": "wik_node_2", "node_token": "wik_node_2",
"obj_token": "docx_2", "obj_token": "docx_2",
"obj_type": "docx", "obj_type": "docx",
@@ -170,7 +313,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
}) })
err := mountAndRunWiki(t, WikiNodeList, []string{ err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot", "+node-list", "--space-id", "7211568716812369922", "--as", "bot",
}, factory, stdout) }, factory, stdout)
if err != nil { if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err) t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -211,14 +354,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
stub := &httpmock.Stub{ stub := &httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent", URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
Body: map[string]interface{}{ Body: map[string]interface{}{
"code": 0, "code": 0,
"data": map[string]interface{}{ "data": map[string]interface{}{
"has_more": false, "has_more": false,
"items": []interface{}{ "items": []interface{}{
map[string]interface{}{ map[string]interface{}{
"space_id": "space_123", "space_id": "7211568716812369922",
"node_token": "wik_child", "node_token": "wik_child",
"obj_token": "docx_child", "obj_token": "docx_child",
"obj_type": "docx", "obj_type": "docx",
@@ -235,7 +378,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
reg.Register(stub) reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{ err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot", "+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
}, factory, stdout) }, factory, stdout)
if err != nil { if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err) t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -286,7 +429,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
"code": 0, "msg": "success", "code": 0, "msg": "success",
"data": map[string]interface{}{ "data": map[string]interface{}{
"space": map[string]interface{}{ "space": map[string]interface{}{
"space_id": "space_personal_42", "space_id": "7211568716812369923",
"name": "My Library", "name": "My Library",
"space_type": "my_library", "space_type": "my_library",
}, },
@@ -296,14 +439,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
// Step 2: list nodes in the resolved space. // Step 2: list nodes in the resolved space.
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes", URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
Body: map[string]interface{}{ Body: map[string]interface{}{
"code": 0, "msg": "success", "code": 0, "msg": "success",
"data": map[string]interface{}{ "data": map[string]interface{}{
"has_more": false, "has_more": false,
"items": []interface{}{ "items": []interface{}{
map[string]interface{}{ map[string]interface{}{
"space_id": "space_personal_42", "space_id": "7211568716812369923",
"node_token": "wik_personal_1", "node_token": "wik_personal_1",
"title": "Personal Note", "title": "Personal Note",
}, },
@@ -334,8 +477,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
if envelope.Meta.Count != 1 { if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count) t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
} }
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" { if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"]) t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
} }
} }
@@ -758,21 +901,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
// test pins down the "default = single page" contract. // test pins down the "default = single page" contract.
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes", URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
Body: map[string]interface{}{ Body: map[string]interface{}{
"code": 0, "msg": "success", "code": 0, "msg": "success",
"data": map[string]interface{}{ "data": map[string]interface{}{
"has_more": true, "has_more": true,
"page_token": "tok_next", "page_token": "tok_next",
"items": []interface{}{ "items": []interface{}{
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"}, map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
}, },
}, },
}, },
}) })
err := mountAndRunWiki(t, WikiNodeList, []string{ err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--as", "bot", "+node-list", "--space-id", "7211568716812369922", "--as", "bot",
}, factory, stdout) }, factory, stdout)
if err != nil { if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err) t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -802,14 +945,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes", URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
Body: map[string]interface{}{ Body: map[string]interface{}{
"code": 0, "msg": "success", "code": 0, "msg": "success",
"data": map[string]interface{}{ "data": map[string]interface{}{
"has_more": false, "has_more": false,
"items": []interface{}{ "items": []interface{}{
map[string]interface{}{ map[string]interface{}{
"space_id": "space_123", "space_id": "7211568716812369922",
"node_token": "wik_1", "node_token": "wik_1",
"obj_type": "docx", "obj_type": "docx",
"obj_token": "docx_1", "obj_token": "docx_1",
@@ -822,7 +965,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
}) })
err := mountAndRunWiki(t, WikiNodeList, []string{ err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot", "+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
}, factory, stdout) }, factory, stdout)
if err != nil { if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err) t.Fatalf("mountAndRunWiki() error = %v", err)

View File

@@ -48,27 +48,19 @@ var WikiNodeList = common.Shortcut{
"--space-id my_library is a per-user alias and is only valid with --as user.", "--space-id my_library is a per-user alias and is only valid with --as user.",
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spaceID := strings.TrimSpace(runtime.Str("space-id")) if _, err := readWikiNodeListSpec(runtime); err != nil {
// my_library is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
return err return err
} }
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize) return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spaceID := strings.TrimSpace(runtime.Str("space-id")) spec, err := readWikiNodeListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := map[string]interface{}{"page_size": runtime.Int("page-size")} params := map[string]interface{}{"page_size": runtime.Int("page-size")}
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" { if spec.ParentNodeToken != "" {
params["parent_node_token"] = pt params["parent_node_token"] = spec.ParentNodeToken
} }
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt params["page_token"] = pt
@@ -80,7 +72,7 @@ var WikiNodeList = common.Shortcut{
// When the caller passes my_library, +node-list must first resolve it // When the caller passes my_library, +node-list must first resolve it
// to the real per-user space_id before listing nodes, mirroring the // to the real per-user space_id before listing nodes, mirroring the
// two-step orchestration used by +node-create. // two-step orchestration used by +node-create.
if spaceID == wikiMyLibrarySpaceID { if spec.SpaceID == wikiMyLibrarySpaceID {
return d. return d.
Desc("2-step orchestration: resolve my_library -> list nodes"). Desc("2-step orchestration: resolve my_library -> list nodes").
GET("/open-apis/wiki/v2/spaces/my_library"). GET("/open-apis/wiki/v2/spaces/my_library").
@@ -91,13 +83,17 @@ var WikiNodeList = common.Shortcut{
Set("space_id", "<resolved_space_id>") Set("space_id", "<resolved_space_id>")
} }
return d. return d.
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))). GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
Params(params). Params(params).
Set("space_id", spaceID) Set("space_id", spec.SpaceID)
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime) warnIfConflictingPagingFlags(runtime)
spaceID := strings.TrimSpace(runtime.Str("space-id")) spec, err := readWikiNodeListSpec(runtime)
if err != nil {
return err
}
spaceID := spec.SpaceID
// Resolve the my_library alias to the per-user real space_id before // Resolve the my_library alias to the per-user real space_id before
// listing, so the subsequent request hits a concrete space endpoint. // listing, so the subsequent request hits a concrete space endpoint.
@@ -110,7 +106,7 @@ var WikiNodeList = common.Shortcut{
spaceID = resolved spaceID = resolved
} }
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID) nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
if err != nil { if err != nil {
return err return err
} }
@@ -127,10 +123,104 @@ var WikiNodeList = common.Shortcut{
}, },
} }
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) { type wikiNodeListSpec struct {
SpaceID string
ParentNodeToken string
}
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
spaceID := strings.TrimSpace(runtime.Str("space-id"))
// my_library is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
}
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
return wikiNodeListSpec{}, err
}
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
if err != nil {
return wikiNodeListSpec{}, err
}
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
}
func validateWikiNodeListSpaceID(spaceID string) error {
if spaceID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
}
if spaceID == wikiMyLibrarySpaceID {
return nil
}
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id must be a numeric wiki space_id, not a URL or path",
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
}
if !isDecimalWikiSpaceID(spaceID) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
}
return nil
}
func isDecimalWikiSpaceID(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
if parentNodeToken == "" {
return "", nil
}
if strings.Contains(parentNodeToken, "://") {
ref, ok := common.ParseResourceURL(parentNodeToken)
if !ok {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token URL is unsupported",
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
}
if ref.Type != "wiki" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must identify a wiki node; got a %s URL",
ref.Type,
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
}
parentNodeToken = ref.Token
}
if strings.ContainsAny(parentNodeToken, "/?#") {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
).WithParam("--parent-node-token")
}
if !looksLikeWikiNodeToken(parentNodeToken) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
}
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
return "", err
}
return parentNodeToken, nil
}
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
pageSize := runtime.Int("page-size") pageSize := runtime.Int("page-size")
startToken := strings.TrimSpace(runtime.Str("page-token")) startToken := strings.TrimSpace(runtime.Str("page-token"))
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
auto := wikiListShouldAutoPaginate(runtime) auto := wikiListShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit") pageLimit := runtime.Int("page-limit")
@@ -153,7 +243,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
} }
data, err := runtime.CallAPITyped("GET", apiPath, params, nil) data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil { if err != nil {
return nil, false, "", err return nil, false, "", wikiNodeListProblem(err, runtime)
} }
items, _ := data["items"].([]interface{}) items, _ := data["items"].([]interface{})
for _, item := range items { for _, item := range items {
@@ -177,6 +267,36 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
return nodes, lastHasMore, lastPageToken, nil return nodes, lastHasMore, lastPageToken, nil
} }
func wikiNodeListProblem(err error, runtime *common.RuntimeContext) error {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
switch p.Code {
case 131002:
msg := strings.ToLower(p.Message)
switch {
case strings.Contains(msg, "page_token"):
appendWikiProblemHint(err, "The page token is invalid or stale. Use only the `page_token` returned by the immediately preceding `wiki +node-list` response, or omit --page-token and start over.")
case strings.Contains(msg, "space_id"):
appendWikiProblemHint(err, "The --space-id value must be the numeric wiki space_id from `wiki +space-list`; do not pass a wiki URL, node token, document token, or title.")
default:
appendWikiProblemHint(err, "Check the wiki +node-list flags. Fix the parameter before retrying; this is not a transient error.")
}
case 131005:
appendWikiProblemHint(err, "The target wiki space or parent node was not found. Re-discover the space with `wiki +space-list` and the parent with `wiki +node-list`/`wiki +node-get`; do not retry the same stale token.")
case 131006:
if runtime != nil && runtime.As().IsBot() {
appendWikiProblemHint(err, "The bot/app identity cannot read this wiki space or node. Grant the app the required wiki scope and ensure the app or bot has access to the target knowledge space.")
} else {
appendWikiProblemHint(err, "The current user cannot read this wiki space or node. Switch to a user with access or ask the space owner to grant read permission.")
}
case 99991400:
appendWikiProblemHint(err, "Rate limited by the wiki API. Stop immediate retries and retry later with exponential backoff or a smaller --page-limit.")
}
return err
}
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} { func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"space_id": common.GetString(m, "space_id"), "space_id": common.GetString(m, "space_id"),

View File

@@ -6,6 +6,7 @@
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable` - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx` - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet` - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder` - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base` - `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` | | `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable | | `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 授权当前应用访问文档 ### 授权当前应用访问文档

View File

@@ -65,7 +65,7 @@
1. `+triage --from spam@x.com` → 列出 N 条结果 1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认" 2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash` 3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份 ## 身份选择:优先使用 user 身份
@@ -82,12 +82,13 @@
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。 1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id` 2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 读单封邮件,`+thread` 读整个会话 3. **阅读**`+message` 读单封邮件,`+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送) 4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送) 5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送 6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **确认投递** 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send` 7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op 8. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **已读回执** 9. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。 - **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。 - **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
## 原生 API 调用规则 ## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。 没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过 ### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -12,7 +12,7 @@ metadata:
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。 开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut** **CRITICAL — 凡涉及预约日程/会议室、调整时间或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。仅编辑字段(改标题/描述)或增删参会人(不涉及时间和会议室)时可跳过,直接读 [`references/lark-calendar-update.md`](references/lark-calendar-update.md)。**
## 身份 ## 身份
@@ -30,28 +30,84 @@ lark-cli calendar +agenda --as user
| Shortcut | 说明 | | Shortcut | 说明 |
|----------|------| |----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) | | `+agenda` | 查看日程安排(默认今天) |
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息meeting_id、meeting_note日程开过视频会议才会有meeting_id | | [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息meeting_id、meeting_note日程开过视频会议才会有meeting_id |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) | | [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 | | [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 | | `+freebusy` | 查询用户主日历的忙闲信息和 RSVP 状态(纯查询场景;预约场景走 `+suggestion` |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion | | [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion |
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) | | [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 | | [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
### `+get` — 单日程详情
通过 `calendar_id` + `event_id` 获取**单个日程**详情。
```bash
# calendar_id不传默认primary
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
```
### `+search-event` — 按关键词、时间范围和参会人搜索日程
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`
```bash
# query 按关键词 可选
# start/end 按时间范围ISO 8601 或 YYYY-MM-DD可选
# attendee-ids 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)可选
# page-token 分页游标,用于继续翻页 可选
# page-size 每页数量,默认 30 可选
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1,oc_chat1,omm_room1" --page-token <page_token> --page-size 30
```
### `+agenda` — 查看近期日程安排
默认查询当天。结果应整理为按日期分组、按开始时间升序的易读时间线。
```bash
# start/end 时间范围ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
# calendar-id 日历 ID默认primary可选
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17 --calendar-id <calendar_id>
```
注意:
- 已取消的日程自动过滤;无日程时直接告知"日程清空"。
- 时间范围超过 40 天会自动拆分查询并合并结果。
### `+freebusy` — 查询主日历忙闲时段和 RSVP 状态
仅返回忙碌时段起止时间,不含日程标题等隐私信息;其他订阅日历不在范围内。
```bash
# start/end 时间范围ISO 8601 / YYYY-MM-DD / Unix 秒),均可选;默认当天
# user-id 目标用户 open_idou_ 前缀可选默认当前登录用户bot 身份必须显式指定
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
```
用法提示:
- **仅判断是否有空** → `+freebusy`**需要日程详情** → `+agenda`
- 检查多人可用性:分别调用并对比,找共同空闲。
- 预约/改约场景下,调用规则(参与人过多、含群组、来自 `+suggestion` 等)详见 [schedule-clear-time.md § 查询忙闲](references/lark-calendar-schedule-clear-time.md#2-查询忙闲)。
## 前置条件路由 ## 前置条件路由
| 场景 | 前置要求 | | 场景 | 前置要求 |
|------|----------| |------|----------|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) | | 预约日程/会议、调整时间、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID | | 编辑字段(标题/描述)或增删参会人 | 先定位 `event_id`,再读 [lark-calendar-update.md](references/lark-calendar-update.md) |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 | | 编辑已有日程(涉及时间或会议室) | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
| 调用任何 Shortcut | 先读其对应 reference 文档 | | 调用任何 Shortcut | 先读其对应 reference 文档 |
## 写操作反馈
创建、更新、删除、RSVP 等写操作完成后,直接基于命令返回结果反馈用户;不要为了“确认是否生效”主动发起二次查询。只有用户明确要求复查,或命令返回信息不足以回答用户问题时,才需要再查询。
## 核心概念 ## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程 `event_id` - **日程实例Instance**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外
- **重复性日程例外Exception**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。 - **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。 - **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。 - **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
@@ -70,7 +126,9 @@ lark-cli calendar +agenda --as user
| 按关键词搜索日程 | 本 skill`+search-event` | | 按关键词搜索日程 | 本 skill`+search-event` |
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` | | 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` |
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) | | 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) | | 预约/改约日程、调整时间、添加/更换会议室、查会议室 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
| 仅编辑日程字段(标题/描述)或增删参会人(不涉及时间和会议室) | 先定位 `event_id`,再读 [+update](references/lark-calendar-update.md) 执行变更 |
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
## 任务类型分流 ## 任务类型分流
@@ -87,7 +145,7 @@ lark-cli calendar +agenda --as user
## 会议室规则 ## 会议室规则
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。 - 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md),会议室参数规范详见 [+room-find](references/lark-calendar-room-find.md)
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。 - `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。 - 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。 - 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
@@ -95,42 +153,45 @@ lark-cli calendar +agenda --as user
## API Resources ## API Resources
```bash ```bash
# 通用调用格式
lark-cli calendar <resource> <method> [flags] lark-cli calendar <resource> <method> [flags]
# 查询用户主日历
lark-cli calendar calendars primary
# 获取日程分享链接
lark-cli calendar events share_info --calendar-id <calendar_id> --event-id <event_id>
# 删除日程
lark-cli calendar events delete --calendar-id <calendar_id> --event-id <event_id>
``` ```
### calendars > `calendar_id` 可以直接传 `primary`,代表当前调用身份的主日历 ID。
- `create` — 创建共享日历 ### 查询资源的方法列表以及方法的使用方式
- `delete` — 删除共享日历
- `get` — 查询日历信息
- `list` — 查询日历列表
- `patch` — 更新日历信息
- `primary` — 查询用户主日历
- `search` — 搜索日历
### event.attendees - 列出某资源下的方法:`lark-cli calendar <resource> -h`
- 查看方法的cli flag`lark-cli calendar <resource> <method> -h`
- 查看方法API参数`lark-cli schema calendar.<resource>.<method>`
- `batch_delete` — 删除日程参与人 `<resource>``calendars`(日历本身)/ `events`(日程)/ `event.attendees`(参与人)/ `freebusys`(忙闲)。例:`lark-cli schema calendar.events.delete`
- `create` — 添加日程参与人
- `list` — 获取日程参与人列表
### events ## 常用其他域命令
- `create` — 创建日程 ```bash
- `delete` — 删除日程 # 搜索用户,更多参数详见 lark-contact
- `get` — 获取日程 lark-cli contact +search-user --query <query> --as user
- `instance_view` — 查询日程视图
- `patch` — 更新日程
- `share_info` — 获取日程分享链接
### freebusys # 搜索群聊,更多参数详见 lark-im
lark-cli im +chat-search --query <query> --as user
- `list` — 查询主日历日程忙闲信息 ```
## 不在本 skill 范围 ## 不在本 skill 范围
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md) - 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 待办任务管理 → [lark-task](../lark-task/SKILL.md) - 待办任务管理 → [lark-task](../lark-task/SKILL.md)
- 通讯录 → [lark-contact](../lark-contact/SKILL.md)
- 即时通讯 → [lark-im](../lark-im/SKILL.md)
- 会议室物理设施管理 → 管理员后台 - 会议室物理设施管理 → 管理员后台
**注意(强制性):** **注意(强制性):**

View File

@@ -1,78 +0,0 @@
# calendar +agenda
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查看近期日程安排。只读操作,不修改任何日程。
需要的scopes: ["calendar:calendar.event:read"]
## 命令
```bash
# 查看今天日程(默认)
lark-cli calendar +agenda
# 自定义时间范围ISO 8601
lark-cli calendar +agenda --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
# 自定义时间范围(仅日期)
lark-cli calendar +agenda --start 2026-03-10 --end 2026-03-17
# 人类可读格式输出
lark-cli calendar +agenda --format pretty
# 指定日历
lark-cli calendar +agenda --calendar-id cal_xxx
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--start <time>` | 否 | 开始时间ISO 8601 或仅日期,默认当天) |
| `--end <time>` | 否 | 结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--format` | 否 | 输出格式json默认 \| pretty |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 时间格式
`--start``--end` 支持以下格式:
| 格式 | 示例 | 说明 |
|------|------|------|
| ISO 8601 | `2026-03-10T14:00:00+08:00` | 完整格式 |
| 日期+时间 | `2026-03-10 14:00:00` | 自动补全时区 |
| 仅日期 | `2026-03-10` | start 取 00:00:00end 取 23:59:59 |
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
## 输出格式
**将结果整理为易读的日程表:**
```
## 2026-03-10 周一
09:00 - 09:30 站会
10:00 - 11:00 产品评审
14:00 - 15:00 与 Alice 1:1
## 2026-03-11 周二
(无日程)
```
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长
## 提示
- 已取消的日程会自动过滤,无需额外处理。
- 如无日程,告知用户"日程清空"。
- 大于 40 天的时间范围会自动拆分查询并合并结果。
- 查看多个日历:先用 `lark-cli calendar calendars list --page-all` 列出日历列表,再逐个查询。
## 参考
- [lark-calendar](../SKILL.md) -- 日历全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1,12 +1,9 @@
# calendar +create # calendar +create
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建日程并按需邀请参会人。 创建日程并按需邀请参会人。
需要的scopes: ["calendar:calendar.event:create","calendar:calendar.event:update"]
## 推荐命令 ## 推荐命令
```bash ```bash
@@ -38,15 +35,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 | | `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 | | `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 | | `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" | | `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--dry-run` | 否 | 预览 API 调用,不执行 | | `--dry-run` | 否 | 预览 API 调用,不执行 |
> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。** > 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。 > 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。 > 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。 > 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。 > 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。 > 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令) ## 高级用法(完整 API 命令)
@@ -55,37 +53,16 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。 - 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
```bash ```bash
# 第一步:创建日程(含高级参数) ## 添加需要审批的会议室approval_reason 最大 200 字符)
## 查看完整参数定义
lark-cli schema calendar.events.create
## 创建日程
lark-cli calendar events create \
--params '{"calendar_id":"<CALENDAR_ID>"}' \
--data '{
"summary": "技术分享CLI 架构设计",
"start_time": { "timestamp": "1741586400" },
"end_time": { "timestamp": "1741593600" }
}'
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id
## 查看完整参数定义
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \ lark-cli calendar event.attendees create \
--as user \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \ --params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}' --data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程 完整 API 命令的关键差异:
## 查看完整参数定义 - 时间参数是 **Unix 秒字符串**(非 ISO 8601
lark-cli schema calendar.events.delete - 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天;单日全天日程两者相同。
## 删除空日程 - 手动拆成“创建日程 + 添加参会人”两步时,若第二步失败,建议删除刚创建的空日程,避免遗留无参会人的日程。
lark-cli calendar events delete \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
```
> 完整 API 命令的时间参数是 **Unix 秒字符串**(非 ISO 8601
> 当你手动拆成两步执行时,建议保留“失败后回滚删除”的第三步,避免遗留空日程。
## 参会人类型 ## 参会人类型
@@ -101,6 +78,5 @@ lark-cli calendar events delete \
## 参考 ## 参考
- [lark-calendar](../SKILL.md) -- 日历全部命令 - [lark-calendar](../SKILL.md) -- skill 入口与路由
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案 - [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案

View File

@@ -1,124 +0,0 @@
# calendar +freebusy
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
查询用户主日历的忙闲信息返回指定时间范围内的忙碌时段列表和rsvp的状态。
需要的scopes: ["calendar:calendar.free_busy:read"]
## 命令
```bash
# 查询当前用户今天的忙闲(默认)
lark-cli calendar +freebusy
# 自定义时间范围(仅日期)
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12
# 自定义时间范围(完整 ISO 8601
lark-cli calendar +freebusy --start "2026-03-11T08:00:00+08:00" --end "2026-03-11T18:00:00+08:00"
# 查询指定用户的忙闲信息
lark-cli calendar +freebusy --start 2026-03-11 --end 2026-03-12 --user-id ou_xxx
# 人类可读格式输出
lark-cli calendar +freebusy --format pretty
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--start <time>` | 否 | 查询开始时间ISO 8601 或仅日期,默认当天) |
| `--end <time>` | 否 | 查询结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
| `--user-id <open_id>` | 否 | 目标查询用户 ID`ou_` 前缀。省略时默认查询当前登录用户bot 身份调用时必须明确指定 |
| `--format` | 否 | 输出格式json默认 \| pretty |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 时间格式
`--start``--end` 支持以下格式:
| 格式 | 示例 | 说明 |
|------|------|------|
| ISO 8601 | `2026-03-11T09:00:00+08:00` | 完整格式 |
| 日期+时间 | `2026-03-11 09:00:00` | 自动补全时区 |
| 仅日期 | `2026-03-11` | start 取 00:00:00end 取 23:59:59 |
| Unix 时间戳 | `1741564800` | 秒级时间戳 |
## 输出示例
### 表格格式
```
start end rsvp_status
---------------- ---------------- -----------
2026-03-11 10:00 2026-03-11 10:30 接受
2026-03-11 14:00 2026-03-11 15:00 待定
共 2 个忙碌时段
```
### JSON 格式
```json
[
{
"start_time": "2026-03-11T10:00:00+08:00",
"end_time": "2026-03-11T10:30:00+08:00",
"rsvp_status": "accept"
},
{
"start_time": "2026-03-11T14:00:00+08:00",
"end_time": "2026-03-11T15:00:00+08:00",
"rsvp_status": "tentative"
}
]
```
## 典型场景
### 1. 查找日程会议空闲时段
```bash
# 查询今天的忙碌时段
lark-cli calendar +freebusy
# 查询工作时间段
lark-cli calendar +freebusy \
--start "2026-03-11T08:00:00+08:00" \
--end "2026-03-11T18:00:00+08:00"
```
### 2. 检查团队成员可用性
```bash
# 查询多个成员,对比找出共同空闲时间
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_a
lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b
```
## 注意事项
1. **只查询主日历** — 此命令只返回用户主日历的忙闲信息,不包括其他订阅日历
2. **隐私保护** — 只返回忙碌时段的起止时间,不包含日程标题、描述等详细信息
3. **bot 身份** — bot 必须通过 `--user-id` 指定要查询的用户
## 与其他命令对比
| 命令 | 用途 | 输出内容 |
|------|------|----------|
| `calendar +freebusy` | 查询忙闲时段 | 只返回忙碌时段列表(无日程详情) |
| `calendar +agenda` | 查看日程安排 | 返回完整日程列表(含标题、描述等) |
**选择建议**
- **仅需了解是否有空** → 使用 `+freebusy`(更快,隐私保护)
- **需要查看日程详情** → 使用 `+agenda`
## 参考
- [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排
- [lark-calendar-create](lark-calendar-create.md) — 创建日程
- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案
- [lark-calendar](../SKILL.md) — 日历完整 API

View File

@@ -0,0 +1,90 @@
# 重复性日程操作规范
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
## 关键概念
- **event_id 结构**`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime``0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性标题、时间、rrule、描述等都挂在本体上。
- **例外Exception**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}``originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
## 前置步骤(所有范围通用)
1. 通过 `+agenda``+search-event` 定位重复性日程,获取原重复性日程的 `event_id`
2. 通过 `events instance_view``+agenda` 列出实例,识别哪些是例外(`event_id``originalTime > 0` 的即为例外)。
3. 确认用户的操作范围。
## 编辑全部(更新时间)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
## 编辑全部(更新非时间字段)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
## 删除全部
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
## 编辑此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
## 删除此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
> 与「编辑此次及后续」的区别:不需要步骤 3创建新的重复性日程因为目标是删除后续而非替换。
## 仅此次
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`
## 用户意图映射
| 用户表达 | 操作范围 |
|----------|----------|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
| 「就改这一次」「只删这一次」 | 仅此次 |
| 未明确范围 | **必须询问用户** |
## 注意事项
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
## 参考
- [lark-calendar](../SKILL.md) — 日历全部命令
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,11 +1,8 @@
# calendar +room-find # calendar +room-find
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。 针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。
需要的 scopes: ["calendar:calendar.free_busy:read"]
## 适用场景 ## 适用场景
- 已知一个或多个待选时间块,需要查找可用会议室 - 已知一个或多个待选时间块,需要查找可用会议室
@@ -50,7 +47,7 @@ lark-cli calendar +room-find \
| `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 | | `--city <text>` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 |
| `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。| | `--building <text>` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。|
| `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意此参数只筛选楼层不可混入区域定位如“A区”或具体会议室号。 | | `--floor <text>` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意此参数只筛选楼层不可混入区域定位如“A区”或具体会议室号。 |
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室``木星``会议室 02` / `02会议室``02`。 | | `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名会议室号或编号区间时使用。 |
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 | | `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 |
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 | | `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 | | `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 |
@@ -67,7 +64,7 @@ lark-cli calendar +room-find \
- 同一语义槽位只保留一个规范值。例如用户说“2楼”应转换为 `--floor "F2"`**禁止**同时传 `2楼 F2` 这类重复楼层信息。 - 同一语义槽位只保留一个规范值。例如用户说“2楼”应转换为 `--floor "F2"`**禁止**同时传 `2楼 F2` 这类重复楼层信息。
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L``2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"``--floor "F2"` - 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L``2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"``--floor "F2"`
- 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"``会议室 02` / `02会议室` 应提取为 `--room-name "02"` - 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"``会议室 02` / `02会议室` 应提取为 `--room-name "02"`
- **多会议室名称场景**当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如: - 当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如:
- "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"` - "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"`
- "查下木星和火星是否有空" → `--room-name "木星,火星"` - "查下木星和火星是否有空" → `--room-name "木星,火星"`
- "看看 01、02、03 会议室" → `--room-name "01,02,03"` - "看看 01、02、03 会议室" → `--room-name "01,02,03"`
@@ -90,9 +87,8 @@ lark-cli calendar +room-find \
``` ```
> **AI 行为指导:** > **AI 行为指导:**
> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称 > - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示,并直接询问用户意向
> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。 > - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。
> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。
> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。 > - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。
> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。 > - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。
> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。 > - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。
@@ -102,7 +98,7 @@ lark-cli calendar +room-find \
| 字段名 | 说明 | | 字段名 | 说明 |
| :--- | :--- | | :--- | :--- |
| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 | | `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 |
| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 | | `room_name` | 会议室名称,展示给用户时必须使用原值。 |
| `capacity` | 会议室最大容纳人数。 | | `capacity` | 会议室最大容纳人数。 |
| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 | | `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 |
@@ -110,4 +106,4 @@ lark-cli calendar +room-find \
- [lark-calendar-create](lark-calendar-create.md) - [lark-calendar-create](lark-calendar-create.md)
- [lark-calendar-suggestion](lark-calendar-suggestion.md) - [lark-calendar-suggestion](lark-calendar-suggestion.md)
- [lark-calendar](../SKILL.md) — 日历完整 API - [lark-calendar](../SKILL.md) — skill 入口与路由

View File

@@ -1,11 +1,8 @@
# calendar +rsvp # calendar +rsvp
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。 回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
需要的scopes: ["calendar:calendar.event:reply"]
## 命令 ## 命令
```bash ```bash
@@ -38,5 +35,4 @@ lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status a
## 参考 ## 参考
- [lark-calendar](../SKILL.md) -- 日历全部命令 - [lark-calendar](../SKILL.md) -- skill 入口与路由
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,59 @@
# 明确时间分支room-find + freebusy + 冲突处理
> 本文档处理**时间已明确**的场景。"明确时间"来源:用户直接表达(如"明天下午3点")、编辑流中已定位日程的原始 start/end、或经用户确认的 suggestion 时间块。
## 前置条件
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
- 任务类型判定(新建 / 编辑)
- 编辑流:目标 event_id 已定位
- 新建流:默认值已补全
- 时间已判定为**明确**
## 流程
### 1. 查询会议室(如需)
若用户需要会议室,先调用 `+room-find`。详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
```bash
lark-cli calendar +room-find \
--slot "<start>~<end>" \
--attendee-ids "<ids>" \
--city "<city>" \
--building "<building>" \
--floor "<F2>" \
--room-name "<room_name>"
```
时间块确定规则:
- **编辑流且不改时间,只新增会议室**`--slot` 必须来自已定位日程的当前 `start/end`
- **编辑流且既改时间又加会议室**`--slot` 必须来自候选新时间,而不是旧时间
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
### 2. 查询忙闲
```bash
lark-cli calendar +freebusy --start "<start>" --end "<end>"
```
规则:
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
- 参与人含**群组**:无需展开群组成员查询忙闲
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
### 3. 冲突处理
- **无冲突**:直接让用户选择会议室(如需),进入落地操作
- **有冲突**:必须先说明冲突情况,询问用户:
- **继续当前时间** → 让用户选择会议室(如需),进入落地操作
- **换时间** → 转入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
## 落地
根据任务类型:
- 新建 → [`+create`](./lark-calendar-create.md)
- 编辑 → [`+update`](./lark-calendar-update.md)
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。

View File

@@ -0,0 +1,88 @@
# 模糊时间 / 无时间信息分支suggestion + 批量查询
> 本文档处理**时间模糊**(如"明天下午""下周找个时间")或**完全无时间信息**的场景。核心动作是调用 `+suggestion` 产出候选时间块,再根据是否需要会议室决定后续步骤。
## 前置条件
进入此分支前,调度器([schedule-meeting.md](./lark-calendar-schedule-meeting.md))已完成:
- 任务类型判定(新建 / 编辑)
- 编辑流:目标 event_id 已定位
- 新建流:默认值已补全
- 时间已判定为**模糊**或**无时间信息**
## 流程
### 1. 调用 suggestion
详见 [`lark-calendar-suggestion.md`](./lark-calendar-suggestion.md)。
```bash
lark-cli calendar +suggestion \
--start "<range_start>" \
--end "<range_end>" \
--attendee-ids "<ids>" \
--duration-minutes <n> \
--event-rrule "<rrule>"
```
规则:
- 用户完全没有提供时间信息时,先默认一个合理区间(如"今天剩余时间"或"近两天")再调用
- 编辑流中,若用户说"改到明天下午""下周找个时间再约",基于用户期望的**新时间范围**调用,不要沿用旧时间
- **不要在用户完全没给时间时反问"你想约什么时候"** — 先补合理区间再进入 suggestion
### 2. 分支处理
#### 不需要会议室
获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入落地操作。
#### 需要会议室
获取候选时间块后,**不要急于让用户只选时间**。先将这些时间块一次性交给 `+room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化展示,让用户一次性完成选择。
> **注意**:即使用户最初只说"查会议室"且未带时间,也必须强制走 suggestion → room-find 路径。
详见 [`lark-calendar-room-find.md`](./lark-calendar-room-find.md)。
### 3. 用户确认后
- 用户选中 `+suggestion` 返回的时间块后,**无需再次调用 `+freebusy`**,直接进入落地操作
- **BLOCKING REQUIREMENT**:必须先向用户展示选项并等待确认,禁止在未获用户确认时直接创建/更新日程
## 模糊语义消解与长期记忆
针对存在歧义的时间场景,严禁主观臆断。典型例子:
- "上班后" / "下班前"
- 未明确上下午的 12 小时制时间
处理规则:
- 主动澄清真实意图,不自行猜测
- 用户澄清后,将个性化定义沉淀为长期偏好
## 用户展示格式
向用户展示多个时间块及对应会议室时,**必须结构化分行排版**,严禁将时间与会议室放在同一行:
```text
## 2026-03-27 周五
[选项 1] 14:00 - 15:00参会人均空闲
可用会议室:
1. 学清嘉创大厦B座-F2-02🎦(7人)
2. 学清嘉创大厦B座-F2-05🎦(10人)
[选项 2] 16:00 - 17:00参会人均空闲
可用会议室:
1. 学清嘉创大厦B座-F3-01🎦(6人)
2. 学清嘉创大厦B座-F3-06🎦(8人)
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
```
## 落地
根据任务类型:
- 新建 → [`+create`](./lark-calendar-create.md)
- 编辑 → [`+update`](./lark-calendar-update.md)
落地规则详见 [schedule-meeting.md § 落地日程变更](./lark-calendar-schedule-meeting.md#落地日程变更)。

View File

@@ -1,206 +1,95 @@
# 预约/改约日程或会议、查询/搜索可用会议室的工作流 # 预约/改约日程或会议、查询/搜索可用会议室的工作流
## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则) ## 执行摘要
- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。 - **第一步永远是判断任务类型:新建日程,还是编辑已有日程。**
- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程``这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。 - **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。**
- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答 - **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,仅在必须决策的冲突或无法唯一确定的场景下才发起询问
- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。 - **新建流先补默认值,编辑流先继承已定位日程信息。**
- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。 - **明确时间** → 进入 [明确时间分支](./lark-calendar-schedule-clear-time.md)
- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。 - **模糊时间或无时间信息** → 进入 [模糊时间分支](./lark-calendar-schedule-fuzzy-time.md)
- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除 - **BLOCKING REQUIREMENT**: 面临时间方案或会议室方案的选择时,必须先向用户展示选项并等待确认,禁止未经确认直接创建/更新日程
- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion` - **必须按顺序执行。** 不要跳过"任务类型判定""目标日程定位(编辑流)""补默认值/继承基线信息""判断时间明确性"这些前置步骤
- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。
- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。**
- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。
- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。**
- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。
> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。**
## 严禁行为 ## 严禁行为
- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md``lark-calendar-suggestion.md`)的情况下直接调用命令** 必须先阅读文档掌握最新参数要求与规范。 - **严禁在未读取对应子命令文档直接调用命令**
- **严禁在尚未判断新建还是编辑之前,就直接进入创建日程或查会议室动作。** - **严禁在尚未判断"新建"还是"编辑"之前,就直接进入创建日程或查会议室动作。**
- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求当成新建日程。** 这类请求必须先定位目标日程。 - **严禁把带有既有日程锚点 + 修改动词的请求当成新建日程。**
- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室 - **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一 `event_id` 前,不得调用 `+update`
- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find` - **严禁在面临时间/会议室方案选择时,未经用户确认就擅自创建/更新日程。**
- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`
- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。
- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。**
- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。**
- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。**
## 适用场景 ## 适用场景
- 帮我约个会 - "帮我约个会" / "下周找时间和 XX 开会"
- “下周找时间和 XX 开会” - "帮我订/找/搜索一个可用会议室"
- “帮我订个会议室” - "明天下午3点约个日程"
- “帮我找/搜索一个可用的会议室” - "把明天上午的日程加上 小明"
- “帮我推荐一个我以前常用的会议室 - "给下周一的周会换个会议室"
- “查询明天下午可用的会议室” - "把这个日程改到明天下午,并加上学清 F201"
- “明天下午3点约个日程/日历”
- “把明天上午的日程‘产品发布会’加上 小明
- “给下周一的周会换个会议室”
- “把这个日程改到明天下午,并加上学清 F201”
## 核心概念 ## 核心概念
- **会议室是日程的一种参与人attendee / resource不能脱离日程单独预定。** - **会议室是日程的一种参与人attendee / resource不能脱离日程单独预定。**
- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。 - **预定或查找会议室,均需先确定时间块。**
- **当用户说"查会议室""找会议室",默认意图是查会议室可用性,不是检索会议室资源名录。**
## CRITICAL 约束
- **在调用任何具体的 CLI 子命令(如 `+room-find``+suggestion``+freebusy``+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。
- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。**
- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。**
## 任务类型判定 ## 任务类型判定
| 类型 | 典型语言信号 | 第一动作 | | 类型 | 典型语言信号 | 第一动作 |
|------|--------------|----------| |------|--------------|----------|
| 新建日程 | 约个会”“安排一个会议”“新建日程”“帮我订个会议室开会 | 补默认值,再进入时间判断 | | 新建日程 | "约个会""安排会议""新建日程""订个会议室开会" | 补默认值,再进入时间判断 |
| 编辑已有日程 | 给某日程加人/删人/加群/加会议室”“把某日程改到…”“给这场会换个会议室 | 先定位目标日程 `event_id`,再进入后续流程 | | 编辑已有日程 | "给某日程加人/删人/加会议室""把某日程改到…""换会议室" | 先定位目标 `event_id` |
进一步规则: 规则:
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程``这场会`)和**修改动词**(添加、移除、改到、换),默认判定为编辑。
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`
- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程``这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。 ## 编辑流:先定位目标日程
- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。
## 工作流
### 1. 编辑已有日程:先定位目标日程
一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。
定位规则: 定位规则:
- 优先利用用户给出的标题、日期、时间范围等锚点,通过 `+agenda``+search-event` 或实例视图缩小范围
- 命中多个候选日程时,必须向用户展示候选项并要求确认
- 重复性日程必须继续定位到该次实例的 `event_id`
- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda``+search-event` 或实例视图缩小范围。 编辑流分支路由:
- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。
- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`
编辑流分支规则: | 编辑子场景 | 下一步 |
|-----------|--------|
| 仅增删普通参会人/群组,不改时间,不涉及会议室 | 直接 `+update`(详见 [lark-calendar-update.md](./lark-calendar-update.md) |
| 新增会议室,不改时间 | 基于已定位日程 start/end → [明确时间分支](./lark-calendar-schedule-clear-time.md) |
| 只改时间,不涉及会议室 | 判断时间明确性 → 对应分支 |
| 既改时间,又新增/更换会议室 | 先确定最终时间 → 再查会议室 → 落地 |
- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update` ## 新建日程:智能推断默认值
- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。
- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。
- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。
- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`
### 2. 新建日程:智能推断默认 - **标题**:根据上下文自动生成;如无法推断默认"会议"
以下信息智能推断,减少频繁询问用户: - **参会人**:如未指定,默认仅用户自己
- **时长**:基于上下文推断;默认 30 分钟
- **无时间信息**:默认推断合理区间(如"今天"或"近两天"),进入时间推荐流程,禁止询问用户
- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议” 搜索参与人出现多个结果无法唯一确定时,必须询问用户并记录长期记忆。
- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**
- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟
- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户
当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。 ## 判断时间是否明确
### 3. 判断时间是否明确
这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。
时间基准规则: 时间基准规则:
- **新建流**:使用用户给出的时间,或默认补全出的时间范围
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是明确时间
- **编辑流且改时间**:用户想改到的新时间;若表达模糊,进入模糊时间分支
**注意**: 在执行修改日程/会议时间的任务时,必须先获取原日程的持续时长。如果用户只提供了新的开始时间,你必须根据原时长自动计算出新的结束时间,严格保持原时长不变,禁止擅自改变原日程的时长。
- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。 ## 分支路由
- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。
- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`
分两类处理: | 判定结果 | 下一步读取 |
|----------|-----------|
| 明确时间 | [schedule-clear-time.md](./lark-calendar-schedule-clear-time.md) |
| 模糊时间 / 无时间信息 | [schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md) |
- **明确时间**如“明天下午3点” ## 落地日程变更
- **模糊时间**:如“明天下午”“下周找个时间”
### 4. 明确时间
明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。
详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。
```bash
# 1. 如果需要会议室,提前查询会议室
lark-cli calendar +room-find \
--slot "<start>~<end>" \
--attendee-ids "<ids>" \
--city "<city>" \
--building "<building>" \
--floor "<F2>" \
--room-name "<room_name>"
# 2. 查询当前用户及其他参会人忙闲
# (如果有多名参会人,需分别调用查询:--user-id "<ou_xxx>"
lark-cli calendar +freebusy --start "<start>" --end "<end>"
```
规则:
- **参会人过多或包含群组时的处理**
- 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。
- 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。
- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`
- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。
- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间
- **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块
- **如果继续选择这个时间**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程
- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city``--building` 不要再重复携带城市前缀。
- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L``2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"``--floor "F2"`
- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"``会议室 02` / `02会议室` -> `--room-name "02"`
-`F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`
### 5. 模糊时间或无时间信息
先调用:
详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。
```bash
lark-cli calendar +suggestion \
--start "<range_start>" \
--end "<range_end>" \
--attendee-ids "<ids>" \
--duration-minutes <n> \
--event-rrule "<rrule>"
```
规则:
- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion`
- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。
- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。
- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。
- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`**
### 6. 模糊语义消解与长期记忆构建
针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括:
- “上班后”
- “下班前”
- 未明确上下午的 12 小时制时间表达
处理规则:
- 应主动澄清真实意图,而不是自行猜测
- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达
### 7. 重复性日程
若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`
必须检查返回中的:
- `reserve_until_time`
若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应:
- 向用户明确说明该会议室最长可约至何时。
- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。
### 8. 落地日程变更
用户确认后调用: 用户确认后调用:
如果是新建会议,详见 [`+create`](./lark-calendar-create.md) - 新建 → [`+create`](./lark-calendar-create.md)
如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。 - 编辑 → [`+update`](./lark-calendar-update.md)
```bash ```bash
lark-cli calendar +create \ lark-cli calendar +create \
@@ -214,52 +103,20 @@ lark-cli calendar +update \
--start "<start>" \ --start "<start>" \
--end "<end>" \ --end "<end>" \
--add-attendee-ids "omm_new_room" --add-attendee-ids "omm_new_room"
# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室
lark-cli calendar +update \
--event-id "<event_id>" \
--remove-attendee-ids "omm_old_room" \
--add-attendee-ids "omm_new_room"
``` ```
规则: 落地规则:
- 新建日程时,可使用 `+create` - 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新猜测目标日程
- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行; - 编辑流中"新增会议室"默认仅追加 `room_id`,不移除已有会议室
- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。 - 仅当用户明确说"更换会议室"时,才同时 `--remove-attendee-ids` 旧 + `--add-attendee-ids`
- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...` - 需要会议室时,将选中的 `room_id` 写入参与人列表
- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids "<room_id>"`;默认保留已有会议室。
- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。
- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。
- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表
- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值
## 用户展示建议
当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。
**推荐展示格式参考:**
```text
## 2026-03-27 周五
[选项 1] 14:00 - 15:00参会人均空闲
可用会议室:
1. 学清嘉创大厦B座-F2-02🎦(7人)
2. 学清嘉创大厦B座-F2-05🎦(10人)
[选项 2] 16:00 - 17:00参会人均空闲
可用会议室:
1. 学清嘉创大厦B座-F3-01🎦(6人)
2. 学清嘉创大厦B座-F3-06🎦(8人)
💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。
```
## 参考 ## 参考
- [lark-calendar-schedule-clear-time.md](./lark-calendar-schedule-clear-time.md)
- [lark-calendar-schedule-fuzzy-time.md](./lark-calendar-schedule-fuzzy-time.md)
- [lark-calendar-room-find.md](./lark-calendar-room-find.md) - [lark-calendar-room-find.md](./lark-calendar-room-find.md)
- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md)
- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md) - [lark-calendar-suggestion.md](./lark-calendar-suggestion.md)
- [lark-calendar-create.md](./lark-calendar-create.md) - [lark-calendar-create.md](./lark-calendar-create.md)
- [lark-shared](../../lark-shared/SKILL.md) - [lark-calendar-update.md](./lark-calendar-update.md)
- [lark-calendar](../SKILL.md) - [SKILL.md](../SKILL.md)

View File

@@ -1,29 +0,0 @@
# calendar +search-event
按关键词、时间范围和参会人搜索日历日程。只读。
## 命令
```bash
# 按关键词
lark-cli calendar +search-event --query "周会"
# 按时间范围ISO 8601 或 YYYY-MM-DD
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
# 组合
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
```
## 输出字段
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more``page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
## 注意事项
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false不要遗漏`page-size` 最大 30。
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。

View File

@@ -1,6 +1,5 @@
# calendar +suggestion # calendar +suggestion
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。
根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。 根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。
@@ -8,8 +7,6 @@
-**当用户需求涉及寻找时间块,且时间未完全确定**(如`今天``近三天``本周``下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。 -**当用户需求涉及寻找时间块,且时间未完全确定**(如`今天``近三天``本周``下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。
-**当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具 -**当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具
需要的scopes: ["calendar:calendar.free_busy:read"]
## 命令 ## 命令
```bash ```bash
@@ -121,5 +118,4 @@ lark-cli calendar +suggestion \
## 参考 ## 参考
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 - [lark-calendar-create](lark-calendar-create.md) — 创建日程
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态 - [lark-calendar](../SKILL.md) — skill 入口与路由
- [lark-calendar](../SKILL.md) — 日历完整 API

View File

@@ -1,13 +1,10 @@
# calendar +update # calendar +update
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新既有日程字段,或独立增量添加/移除参会人和会议室。 更新既有日程字段,或独立增量添加/移除参会人和会议室。
`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。 `+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。
需要的 scopes: ["calendar:calendar.event:update"]
## 推荐命令 ## 推荐命令
```bash ```bash
@@ -43,7 +40,7 @@ lark-cli calendar +update \
| 参数 | 必填 | 说明 | | 参数 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID | | `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` | | `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 | | `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 | | `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
@@ -65,9 +62,9 @@ lark-cli calendar +update \
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids` - 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>` - 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。 - 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程的某一次实例时,必须先通过 `+agenda``+search-event` 或实例视图定位该实例的 `event_id` - 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。 - 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
**⚠️ 高风险操作**: 修改时间时必须先读取原日程时长并计算新 end。如果 end 计算错误,会导致日程时长变化,用户会直接感知,禁止擅自改变原日程的时长。
## 高级用法(完整 API 命令) ## 高级用法(完整 API 命令)
@@ -98,8 +95,6 @@ lark-cli calendar +update \
## 参考 ## 参考
- [lark-calendar](../SKILL.md) -- 日历全部命令 - [lark-calendar](../SKILL.md) -- skill 入口与路由
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流 - [lark-calendar-schedule-meeting](lark-calendar-schedule-meeting.md) -- 预约/改约会议与会议室工作流
- [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室 - [lark-calendar-room-find](lark-calendar-room-find.md) -- 查找可用会议室
- [lark-calendar-freebusy](lark-calendar-freebusy.md) -- 查询忙闲

View File

@@ -1,11 +1,11 @@
--- ---
name: lark-doc name: lark-doc
version: 2.0.0 version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。" description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
metadata: metadata:
requires: requires:
bins: ["lark-cli"] bins: ["lark-cli"]
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
--- ---
# docs # docs
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:** **CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用) 1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构) 2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md) 3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。** **未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
> **格式选择规则(全局):** > **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML(可用 callout、grid、checkbox 等富 block > - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML。
> - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。 > - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
## 快速决策 ## 快速决策
@@ -39,12 +39,14 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID - 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节 - 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化 - 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入 - 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型Mermaid 可由主 Agent 直接插入SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview` - 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download` - 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover` - 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*` - `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview` - 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作 - 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。 - 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理 - 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
@@ -68,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) | | [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) | | [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) | | [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. | | [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) | | [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) | | [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |

View File

@@ -2,14 +2,14 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可: > **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md) > 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义 > 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略 > 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
> >
> **未读完以上文件就生成内容会导致格式错误。** > **未读完以上文件就生成内容会导致格式错误。**
从 XML默认或 Markdown 内容创建一个新的飞书云文档。 从 XML默认或 Markdown 内容创建一个新的飞书云文档。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。 > **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
## 命令 ## 命令
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
## 参考 ## 参考
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略 - [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义 - [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范 - [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档 - [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档 - [`lark-doc-update.md`](lark-doc-update.md) — 更新文档

View File

@@ -0,0 +1,107 @@
# docs history历史版本与回滚
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
## 安全流程
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id``revision_id``edit_time``name/description`)并确认后回滚。
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`
5.`+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
## 按 revision_id 或时间点回滚
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
4. 从最终匹配条目读取 `history_version_id``history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`
候选确认时使用类似格式:
```text
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
```
## 命令
```bash
# 列出历史版本
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
# 翻页
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
# 回滚到指定 history_version_id默认等待 30000ms
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
# 只发起任务,不等待
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
# 查询回滚任务状态
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
```
## 参数
| 命令 | 参数 | 必填 | 说明 |
|-|-|-|-|
| `+history-list` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
| `+history-revert` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
## 返回值要点
`+history-list` 返回:
```json
{
"entries": [
{
"revision_id": 42,
"history_version_id": "11",
"edit_time": "1780000000",
"type": 1,
"name": "版本名",
"description": "版本说明",
"editor_ids": ["ou_xxx"]
}
],
"has_more": true,
"page_token": "page_token"
}
```
`+history-revert` 返回:
```json
{
"task_id": "task_xxx",
"status": "running",
"history_version_id": "11",
"poll_after_ms": 10000
}
```
`+history-revert-status` 返回:
```json
{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": ["blk_xxx"]
}
```
`status` 可能是 `running``done``partial_failed``failed`。当状态是 `partial_failed``failed` 时,优先检查 `failed_block_tokens`

View File

@@ -0,0 +1,113 @@
# 飞书思维笔记Mindnote
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`
> [!IMPORTANT]
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 命令
```bash
# 先看命令帮助
lark-cli mindnotes nodes list --help
lark-cli mindnotes nodes create --help
# 读取节点列表
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
# 创建子节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
# 更新已有节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
```
## 参数
### `mindnotes nodes list`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
返回重点:`data.nodes` 中常见字段有 `node_id``parent_id``texts``notes``images``finish``highlight`
### `mindnotes nodes create`
命令参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
| `--data` | 是 | JSON 请求体 |
请求体字段:
| 字段 | 必填 | 说明 |
|------|------|------|
| `client_token` | 否 | 幂等 token建议写操作传入推荐使用时间戳或 UUID |
| `nodes` | 是 | 待创建或更新的节点数组 |
| `nodes[].node_id` | 否 | 节点 ID传入已有 `node_id` 时表示更新对应节点 |
| `nodes[].parent_id` | 否 | 父节点 ID创建子节点时传入 |
| `nodes[].texts` | 否 | 节点正文富文本数组 |
| `nodes[].notes` | 否 | 节点备注富文本数组 |
| `nodes[].images` | 否 | 节点图片列表 |
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
| `nodes[].finish` | 否 | 节点完成状态 |
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
```json
[{"element_type":"text","text":{"content":"节点内容"}}]
```
### 节点图片(`nodes[].images`
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
```bash
# 先上传图片,拿到 token
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
# 再把 token 写进节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
```
参数说明:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径 |
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
| `--parent-node` | 是 | 传 Mindnote 的 token |
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
## 推荐工作流
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先通过 token 类别判断。
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID避免重试时重复创建或重复更新。
> [!CAUTION]
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -3,8 +3,8 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可: > **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md) > 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义 > 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略 > 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
> >
> **未读完以上文件就生成内容会导致格式错误。** > **未读完以上文件就生成内容会导致格式错误。**
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。 > **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节 画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)
## 最佳实践 ## 最佳实践
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
## 参考 ## 参考
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略 - [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义 - [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范 - [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档 - [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档 - [`lark-doc-create.md`](lark-doc-create.md) — 创建文档

View File

@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
`$URL` 可以是用户给出的 docx/wiki URL也可以是可被 `docs +fetch` 解析的 token。 `$URL` 可以是用户给出的 docx/wiki URL也可以是可被 `docs +fetch` 解析的 token。
## 统计范围
先判断用户要求的是**整篇文档**还是**局部内容**
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数: 如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
```bash ```bash
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
如果 `unknown_blocks``unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。 如果 `unknown_blocks``unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
## 字数遵循校验
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x``[x+1, +∞)``<y``(-∞, y-1]``x-y``[x, y]``x 字左右``[round(0.9x), round(1.1x)]`
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
## 输出示例 ## 输出示例
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下: 输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:

View File

@@ -7,7 +7,7 @@
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行: 通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么 1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进 2. **Execute执行**由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent见步骤三
3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标 3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环 4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
@@ -16,44 +16,32 @@
## 典型 Code-Act Loop 流程 ## 典型 Code-Act Loop 流程
### 步骤一:规划与初始创建(串行) ### 步骤一:规划与撰写(单 Agent 串行)
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
1. 分析用户需求:受众、目的、范围 1. 分析用户需求:受众、目的、范围
2. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比 2. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容 3. `docs +create` 创建并撰写:
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。 - **短文档**一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
- 完整内容留到步骤二,由各 Agent `block_insert_after --block-id <章节标题 block_id>` 分段写入。 - **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**`block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。 - ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
### 步骤二:分段撰写(并行 Agent ### 步骤二:整合审查与画板识别(串行
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到: 4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索 5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取) 6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则。默认用文字只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
### 步骤三:整合审查与画板识别(串行) ### 步骤三:画板处理与润色
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果 7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材 8. 由**主 Agent 自行润色**(不另起内容子 Agent正文始终一人维护文字密集且不易读时优先拆段、加小标题或调整顺序——叙述内容保持成段**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
### 步骤四:画板处理与润色(并行 Agent ### 步骤四:专项校验
8. **优先处理步骤三识别出的画板需求** 9. **字数门禁**如果用户给出任何明确字数要求如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现目标区间、`word_count` 和达标结论
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。 10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现
9. Spawn 内容改写 Agent 定向润色:
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
- 本地图片使用 `docs +media-insert` 插入
## Agent 子任务要求
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
已有画板更新 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。

View File

@@ -1,86 +1,68 @@
# 文档表达组件参考 # 飞书文档写作原则
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范 写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。 ## 一、用户明确要求优先
## 一、核心原则 用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素 ## 二、默认写连贯段落
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
## 二、元素选择指南 用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。 | 内容 | 用什么 | ❌ 别 |
|---|---|---|
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
| 纯短并列项(无描述,如材料清单) | 列表 | — |
| 章节(内容成块、需在目录导航) | 标题层级 | — |
| 场景 | 可选表达方式 | - 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
|--------------------------------------------|---------------------------------------| - **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 | - **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 | - 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
| 简短低风险对比 | `<grid>` 2 列分栏 | - **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
## 三、按体裁写
### 画板意图识别 - **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block高亮块、分栏等粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
- **一般文档**:以可读为先,不堆砌结构。
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化: ## 四、编号与层级
| 内容特征 | 信号词 / 模式 | 推荐画板类型 | - **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
- 公文 / 正式材料常用:「一、→(一)→ 1.→1中文大层级 + 阿拉伯细分层级)。
- 学术 / 技术 / 商业报告「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
- ⚠️ **「一、」只能配「要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号****不跳级**。
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
## 五、飞书特有组件,克制使用
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项全文极少0~1 个),不要每节导语 / 结论都做成高亮块。
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
| 语义 | 背景色 | 文字色 |
|-|-|-| |-|-|-|
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 | | 信息、说明 | `light-blue` | `blue` |
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 | | 成功、推荐 | `light-green` | `green` |
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 | | 警告 / 错误 / 风险 | `light-red` | `red` |
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 | | 注意、待确认 | `light-yellow` | `yellow` |
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 | | 中性、辅助 | `light-gray` | — |
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
**判断规则:** ## 六、写完自检
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
## 三、颜色语义 交付前快速回看:
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格: - **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
- **标题是否滥用**"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
| 语义 | emoji 前缀 | callout 背景色 | 文字色 | - **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用如「一、」配「1.1」)。
|-|-|-|-| - **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
| 信息、说明 | "说明:" | `light-blue` | `blue` |
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
| 中性、辅助 | — | `light-gray` | — |
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
## 四、排版规范
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
## 五、质量自检
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
| 指标 | 自检问题 |
|-|-|
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |

View File

@@ -5,7 +5,7 @@
## 核心方法论 — Code-Act Loop ## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行: 通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么 1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进 2. **Execute执行**由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent见步骤二
3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标 3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环 4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
@@ -23,33 +23,26 @@
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾) - 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids` - 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- 用户明确要改整篇 → `docs +fetch --detail with-ids` - 用户明确要改整篇 → `docs +fetch --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope" - 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解 2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
3. **画板意图识别**:逐章节扫描,`lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节block ID、推荐画板类型、mermaid/SVG路径和源内容片段 3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节block ID、推荐画板类型、mermaid/SVG路径和源内容片段
4. 向用户简要说明改进计划(包含识别出的画板机会) 4. 向用户简要说明改进计划(包含识别出的画板机会)
### 步骤二:定向改写(并行 Agent ### 步骤二:定向改写( Agent 串行
5. **优先处理步骤一识别出的画板候选段落** 5. **优先处理步骤一识别出的画板候选段落**读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。 6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版 - 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性 - 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步 - 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
### 步骤三:验证(串行) ### 步骤三:验证(串行)
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格 7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果 8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
## Agent 子任务要求 ### 步骤四:专项校验(按需执行)
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id` 9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现结果
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。 **上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
已有画板更新 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。

View File

@@ -29,6 +29,7 @@ metadata:
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable` - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx` - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。 - 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。 - 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history` - 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot` - 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
@@ -102,6 +103,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` | | `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 | | `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable | | `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import``job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 权限能力入口 ### 权限能力入口

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