mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
6 Commits
v1.0.67
...
codex/docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3fdf5a02a | ||
|
|
fcac9f058d | ||
|
|
a5c9b69815 | ||
|
|
1401eeec8d | ||
|
|
7d97755de5 | ||
|
|
2487b4e1a6 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,29 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
@@ -1421,7 +1398,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.67",
|
||||
"version": "1.0.66",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
|
||||
// buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。
|
||||
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
mode := effectiveFetchReadMode(runtime)
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
ro := map[string]interface{}{"read_mode": mode}
|
||||
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
|
||||
if v := effectiveFetchStartBlockID(runtime, mode); v != "" {
|
||||
ro["start_block_id"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(runtime.Str("end-block-id")); v != "" {
|
||||
@@ -177,6 +177,72 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
func effectiveFetchReadMode(runtime *common.RuntimeContext) string {
|
||||
mode := rawFetchReadMode(runtime)
|
||||
if shouldUseDocSelectionAnchor(runtime, mode) {
|
||||
if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" {
|
||||
return "range"
|
||||
}
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func rawFetchReadMode(runtime *common.RuntimeContext) string {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
if mode == "" {
|
||||
return "full"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func effectiveFetchStartBlockID(runtime *common.RuntimeContext, mode string) string {
|
||||
if v := strings.TrimSpace(runtime.Str("start-block-id")); v != "" {
|
||||
return v
|
||||
}
|
||||
if mode == "range" && shouldUseDocSelectionAnchor(runtime, rawFetchReadMode(runtime)) {
|
||||
if anchor := docSelectionAnchorStartBlockID(runtime); anchor != "" {
|
||||
return anchor
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shouldUseDocSelectionAnchor(runtime *common.RuntimeContext, mode string) bool {
|
||||
if runtime.Changed("start-block-id") || runtime.Changed("end-block-id") {
|
||||
return false
|
||||
}
|
||||
if runtime.Changed("scope") {
|
||||
return mode == "range"
|
||||
}
|
||||
return mode == "" || mode == "full"
|
||||
}
|
||||
|
||||
func docSelectionAnchorStartBlockID(runtime *common.RuntimeContext) string {
|
||||
ref, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
anchor, ok := parseDocShareSelectionAnchor(ref.Fragment)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
func parseDocShareSelectionAnchor(raw string) (string, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
value = strings.TrimPrefix(value, "#")
|
||||
const prefix = "share-"
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return "", false
|
||||
}
|
||||
anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix))
|
||||
if anchorID == "" {
|
||||
return "", false
|
||||
}
|
||||
return prefix + anchorID, true
|
||||
}
|
||||
|
||||
// effectiveFetchDetail degrades detail options that cannot be represented by
|
||||
// non-XML exports. The original flag value is left intact so callers can still
|
||||
// surface an explicit warning in execute output.
|
||||
@@ -208,7 +274,7 @@ func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[str
|
||||
|
||||
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。
|
||||
func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
mode := effectiveFetchReadMode(runtime)
|
||||
if mode == "" || mode == "full" {
|
||||
return nil
|
||||
}
|
||||
@@ -227,7 +293,7 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
|
||||
case "outline":
|
||||
return nil
|
||||
case "range":
|
||||
if strings.TrimSpace(runtime.Str("start-block-id")) == "" &&
|
||||
if effectiveFetchStartBlockID(runtime, mode) == "" &&
|
||||
strings.TrimSpace(runtime.Str("end-block-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams(
|
||||
errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"},
|
||||
|
||||
@@ -180,6 +180,64 @@ func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyUsesSelectionAnchorFragmentAsRangeStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
}
|
||||
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read_option = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyExplicitFullIgnoresSelectionAnchorFragment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse")
|
||||
mustSetFetchFlag(t, runtime, "scope", "full")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["read_option"]; ok {
|
||||
t.Fatalf("did not expect read_option for explicit full scope: %#v", body["read_option"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyDoesNotAutoReadOrdinaryFragment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", "https://example.larksuite.com/wiki/wikcnToken#blk_plain")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["read_option"]; ok {
|
||||
t.Fatalf("did not expect read_option for ordinary URL fragment: %#v", body["read_option"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyDoesNotAutoReadUnsupportedSelectionAnchorFragments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, doc := range []string{
|
||||
"https://example.larksuite.com/wiki/wikcnToken#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
"https://example.larksuite.com/wiki/wikcnToken#share-",
|
||||
} {
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "doc", doc)
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["read_option"]; ok {
|
||||
t.Fatalf("did not expect read_option for unsupported URL fragment %q: %#v", doc, body["read_option"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -375,6 +433,12 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default scope with selection anchor fragment",
|
||||
setFlags: map[string]string{
|
||||
"doc": "https://example.larksuite.com/wiki/wikcnToken#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
@@ -884,6 +948,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
|
||||
cmd.Flags().String("detail", fetchDefault("detail"), "")
|
||||
cmd.Flags().String("lang", fetchDefault("lang"), "")
|
||||
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
const docsSceneContextKey = "lark_cli_docs_scene"
|
||||
|
||||
type documentRef struct {
|
||||
Kind string
|
||||
Token string
|
||||
Kind string
|
||||
Token string
|
||||
Fragment string
|
||||
}
|
||||
|
||||
func parseDocumentRef(input string) (documentRef, error) {
|
||||
@@ -28,13 +29,13 @@ func parseDocumentRef(input string) (documentRef, error) {
|
||||
}
|
||||
|
||||
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
|
||||
return documentRef{Kind: "wiki", Token: token}, nil
|
||||
return documentRef{Kind: "wiki", Token: token, Fragment: extractDocumentFragment(raw)}, nil
|
||||
}
|
||||
if token, ok := extractDocumentToken(raw, "/docx/"); ok {
|
||||
return documentRef{Kind: "docx", Token: token}, nil
|
||||
return documentRef{Kind: "docx", Token: token, Fragment: extractDocumentFragment(raw)}, nil
|
||||
}
|
||||
if token, ok := extractDocumentToken(raw, "/doc/"); ok {
|
||||
return documentRef{Kind: "doc", Token: token}, nil
|
||||
return documentRef{Kind: "doc", Token: token, Fragment: extractDocumentFragment(raw)}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
|
||||
@@ -62,6 +63,14 @@ func extractDocumentToken(raw, marker string) (string, bool) {
|
||||
return token, true
|
||||
}
|
||||
|
||||
func extractDocumentFragment(raw string) string {
|
||||
idx := strings.Index(raw, "#")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(raw[idx+1:])
|
||||
}
|
||||
|
||||
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
|
||||
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
|
||||
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
|
||||
|
||||
@@ -13,11 +13,12 @@ func TestParseDocumentRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr string
|
||||
name string
|
||||
input string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantFragment string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "docx url",
|
||||
@@ -31,6 +32,13 @@ func TestParseDocumentRef(t *testing.T) {
|
||||
wantKind: "wiki",
|
||||
wantToken: "xxxxxx",
|
||||
},
|
||||
{
|
||||
name: "wiki url with selection anchor",
|
||||
input: "https://example.larksuite.com/wiki/xxxxxx#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
wantKind: "wiki",
|
||||
wantToken: "xxxxxx",
|
||||
wantFragment: "share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
},
|
||||
{
|
||||
name: "doc url",
|
||||
input: "https://example.larksuite.com/doc/xxxxxx",
|
||||
@@ -73,6 +81,9 @@ func TestParseDocumentRef(t *testing.T) {
|
||||
if got.Token != tt.wantToken {
|
||||
t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken)
|
||||
}
|
||||
if got.Fragment != tt.wantFragment {
|
||||
t.Fatalf("parseDocumentRef(%q) fragment = %q, want %q", tt.input, got.Fragment, tt.wantFragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ metadata:
|
||||
|
||||
```bash
|
||||
# 常用示例
|
||||
lark-cli docs +fetch --doc "文档URL或token"
|
||||
lark-cli docs +fetch --doc "文档URL或token;若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
|
||||
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
|
||||
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
|
||||
```
|
||||
|
||||
@@ -17,8 +17,10 @@ lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3
|
||||
|
||||
# 按 block id 区间精读
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
--scope range --start-block-id blkA --end-block-id blkB --detail with-ids
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc --scope range --start-block-id blkA --end-block-id blkB --detail with-ids
|
||||
|
||||
# URL 带 #share 选区锚点时自动局部读取
|
||||
lark-cli docs +fetch --doc 'docURL#share-anchor'
|
||||
|
||||
# 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前)
|
||||
lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
|
||||
@@ -43,6 +43,58 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) {
|
||||
setDocsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" {
|
||||
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" {
|
||||
t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
|
||||
t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunUnsupportedSelectionAnchorFragmentStaysFull(t *testing.T) {
|
||||
setDocsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.body.read_option").Raw; got != "" {
|
||||
t.Fatalf("read_option=%s, want omitted for unsupported selection anchor\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func setDocsDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
Reference in New Issue
Block a user