mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 07:31:22 +08:00
Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.
Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
lists wiki spaces. Default fetches a single page; --page-all walks
every page capped by --page-limit (default 10, 0 = unlimited).
Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
distinguishes "no spaces" from "empty page with has_more" and hints
the caller to resume.
- wiki +node-list (read, scopes: wiki:node:retrieve):
lists nodes in a space or under a parent. Same pagination + format
story as +space-list. Accepts the my_library alias for --space-id
with --as user (resolved via a shared resolveMyLibrarySpaceID helper
extracted from +node-create); rejects my_library upfront for --as bot.
- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
copies a node into a target space or parent. --target-space-id and
--target-parent-node-token are mutually exclusive. Risk is marked
high-risk-write to match the upstream API's danger: true flag, so the
framework requires --yes. Source is preserved; subtree is copied.
Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.
Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
node-copy}.md: documentation for the new shortcuts.
Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
pre-commit check that scans skill reference docs for realistic-looking
Lark token values without the _EXAMPLE_TOKEN placeholder convention,
preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.
Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
membership, declared-narrow-scope pinning, flag validation (page-size
range, page-limit >= 0, target flag exclusivity, my_library + bot
rejection), auto-pagination merging, --page-limit truncation
surfacing next cursor, --page-token single-page mode, empty-slice
serialisation, has_more hint pretty rendering, my_library user-path
resolution, +node-copy copy-to-space / copy-to-parent + body shape,
pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
workflow exercising the shortcut layer against a real tenant.
Reuses an existing my_library node as a host so the test never adds
to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.
Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
skills/lark-minutes/references/lark-minutes-search.md: replace
realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
scripts/check-doc-tokens.sh passes.
Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253
Co-authored-by: liujinkun <liujinkun@bytedance.com>
219 lines
8.3 KiB
Go
219 lines
8.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package wiki
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
wikiNodeListDefaultPageSize = 50
|
|
wikiNodeListMaxPageSize = 50
|
|
)
|
|
|
|
// WikiNodeList lists child nodes in a wiki space or under a parent node.
|
|
var WikiNodeList = common.Shortcut{
|
|
Service: "wiki",
|
|
Command: "+node-list",
|
|
Description: "List wiki nodes in a space or under a parent node",
|
|
Risk: "read",
|
|
// Same exact-match-scope reasoning as +space-list: declare the
|
|
// narrowest scope the upstream API accepts so we don't false-reject
|
|
// tokens that only carry wiki:node:retrieve.
|
|
Scopes: []string{"wiki:node:retrieve"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library, or +space-list to discover other space IDs", Required: true},
|
|
{Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"},
|
|
{Name: "page-size", Type: "int", Default: strconv.Itoa(wikiNodeListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", wikiNodeListMaxPageSize)},
|
|
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
|
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
|
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
|
},
|
|
Tips: []string{
|
|
"Default fetches a single page; pass --page-all to walk every page (large knowledge bases can be huge — keep an eye on --page-limit).",
|
|
"Use --parent-node-token to drill into a sub-directory.",
|
|
"Run +space-list first to discover your space IDs, including the personal document library.",
|
|
"--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 {
|
|
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 output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --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 validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
|
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
|
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
|
params["parent_node_token"] = pt
|
|
}
|
|
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
|
params["page_token"] = pt
|
|
}
|
|
d := common.NewDryRunAPI()
|
|
if wikiListShouldAutoPaginate(runtime) {
|
|
d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
|
}
|
|
// When the caller passes my_library, +node-list must first resolve it
|
|
// to the real per-user space_id before listing nodes, mirroring the
|
|
// two-step orchestration used by +node-create.
|
|
if spaceID == wikiMyLibrarySpaceID {
|
|
return d.
|
|
Desc("2-step orchestration: resolve my_library -> list nodes").
|
|
GET("/open-apis/wiki/v2/spaces/my_library").
|
|
Desc("[1] Resolve my_library space ID").
|
|
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", "<resolved_space_id>")).
|
|
Desc("[2] List nodes").
|
|
Params(params).
|
|
Set("space_id", "<resolved_space_id>")
|
|
}
|
|
return d.
|
|
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
|
Params(params).
|
|
Set("space_id", spaceID)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
warnIfConflictingPagingFlags(runtime)
|
|
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
|
|
|
// Resolve the my_library alias to the per-user real space_id before
|
|
// listing, so the subsequent request hits a concrete space endpoint.
|
|
if spaceID == wikiMyLibrarySpaceID {
|
|
resolved, err := resolveMyLibrarySpaceID(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved))
|
|
spaceID = resolved
|
|
}
|
|
|
|
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Found %d node(s)\n", len(nodes))
|
|
outData := map[string]interface{}{
|
|
"nodes": nodes,
|
|
"has_more": hasMore,
|
|
"page_token": nextToken,
|
|
}
|
|
runtime.OutFormat(outData, &output.Meta{Count: len(nodes)}, func(w io.Writer) {
|
|
renderWikiNodesPretty(w, nodes, hasMore, nextToken)
|
|
})
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
|
|
pageSize := runtime.Int("page-size")
|
|
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
|
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
|
|
auto := wikiListShouldAutoPaginate(runtime)
|
|
pageLimit := runtime.Int("page-limit")
|
|
|
|
apiPath := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))
|
|
|
|
// Non-nil empty slice keeps json output stable as `[]` instead of `null`.
|
|
var (
|
|
nodes = make([]map[string]interface{}, 0)
|
|
pageToken = startToken
|
|
lastHasMore bool
|
|
lastPageToken string
|
|
)
|
|
for page := 0; ; page++ {
|
|
params := map[string]interface{}{"page_size": pageSize}
|
|
if parentNodeToken != "" {
|
|
params["parent_node_token"] = parentNodeToken
|
|
}
|
|
if pageToken != "" {
|
|
params["page_token"] = pageToken
|
|
}
|
|
data, err := runtime.CallAPI("GET", apiPath, params, nil)
|
|
if err != nil {
|
|
return nil, false, "", err
|
|
}
|
|
items, _ := data["items"].([]interface{})
|
|
for _, item := range items {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
nodes = append(nodes, wikiNodeListItem(m))
|
|
}
|
|
}
|
|
lastHasMore, _ = data["has_more"].(bool)
|
|
lastPageToken, _ = data["page_token"].(string)
|
|
if !auto {
|
|
break
|
|
}
|
|
if !lastHasMore || lastPageToken == "" {
|
|
break
|
|
}
|
|
if pageLimit > 0 && page+1 >= pageLimit {
|
|
break
|
|
}
|
|
pageToken = lastPageToken
|
|
}
|
|
return nodes, lastHasMore, lastPageToken, nil
|
|
}
|
|
|
|
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"space_id": common.GetString(m, "space_id"),
|
|
"node_token": common.GetString(m, "node_token"),
|
|
"obj_token": common.GetString(m, "obj_token"),
|
|
"obj_type": common.GetString(m, "obj_type"),
|
|
"parent_node_token": common.GetString(m, "parent_node_token"),
|
|
"node_type": common.GetString(m, "node_type"),
|
|
"title": common.GetString(m, "title"),
|
|
"has_child": common.GetBool(m, "has_child"),
|
|
}
|
|
}
|
|
|
|
func renderWikiNodesPretty(w io.Writer, nodes []map[string]interface{}, hasMore bool, pageToken string) {
|
|
if len(nodes) == 0 {
|
|
if hasMore && pageToken != "" {
|
|
fmt.Fprintln(w, "Current page is empty but the server reports more pages.")
|
|
fmt.Fprintln(w, "Pass --page-all to walk every page, or --page-token to resume from the cursor below:")
|
|
fmt.Fprintf(w, " next page_token: %s\n", pageToken)
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "No wiki nodes found.")
|
|
return
|
|
}
|
|
for i, n := range nodes {
|
|
fmt.Fprintf(w, "[%d] %s\n", i+1, valueOrDash(n["title"]))
|
|
fmt.Fprintf(w, " node_token: %s\n", valueOrDash(n["node_token"]))
|
|
fmt.Fprintf(w, " obj_type: %s\n", valueOrDash(n["obj_type"]))
|
|
fmt.Fprintf(w, " obj_token: %s\n", valueOrDash(n["obj_token"]))
|
|
hasChild, _ := n["has_child"].(bool)
|
|
fmt.Fprintf(w, " has_child: %t\n", hasChild)
|
|
if parent, _ := n["parent_node_token"].(string); parent != "" {
|
|
fmt.Fprintf(w, " parent: %s\n", parent)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
if hasMore && pageToken != "" {
|
|
fmt.Fprintf(w, "Next page token: %s\n", pageToken)
|
|
}
|
|
}
|