From 7df37ed7150d4e3076946a1ad358c2e49306bd07 Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Wed, 24 Jun 2026 22:26:29 +0800 Subject: [PATCH] feat(base): Add Base URL and title resolve shortcuts (#1338) * feat(base): add URL and title resolve shortcuts * docs: clarify base coordinate resolution * fix(base): address resolve shortcut ci * fix(base): format resolved record share hint * fix(base): simplify record share hint data * fix(base): use field ids in resolved record data * fix(base): guide record share resolve to update record * fix(base): include record upsert example in resolve hint * fix(base): reject add-record urls in resolver * fix(base): validate title resolve query length * fix(base): hide resolve alias flags from help * fix(base): prefer title flag for title resolve * docs(base): clarify token resolution wording --- shortcuts/base/base_resolve.go | 545 ++++++++++++++++++++++++++ shortcuts/base/base_resolve_test.go | 454 +++++++++++++++++++++ shortcuts/base/base_shortcuts_test.go | 1 + shortcuts/base/shortcuts.go | 2 + skills/lark-base/SKILL.md | 29 +- 5 files changed, 1012 insertions(+), 19 deletions(-) create mode 100644 shortcuts/base/base_resolve.go create mode 100644 shortcuts/base/base_resolve_test.go diff --git a/shortcuts/base/base_resolve.go b/shortcuts/base/base_resolve.go new file mode 100644 index 00000000..3cc6b93e --- /dev/null +++ b/shortcuts/base/base_resolve.go @@ -0,0 +1,545 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title." + baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks" + nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks" + nextStepRecordList = "use +record-list to list records in the resolved table" + titleResolveQueryMaxLen = 30 +) + +var BaseURLResolve = common.Shortcut{ + Service: "base", + Command: "+url-resolve", + Description: "Resolve a Base-related URL into Base coordinates", + Risk: "read", + Scopes: []string{}, + ConditionalScopes: []string{ + "base:field:read", + "base:record:read", + "wiki:node:retrieve", + }, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + {Name: "url", Desc: "Base/Wiki/record-share URL to resolve"}, + {Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"}, + }, + Tips: []string{ + `Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/?table=&view="`, + "Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readURLResolveInput(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + raw, err := readURLResolveInput(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + parsed, err := parseResolveURL(raw) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + switch classifyBaseURL(parsed) { + case "wiki_url": + return common.NewDryRunAPI(). + GET("/open-apis/wiki/v2/spaces/get_node"). + Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")}) + case "record_share_url": + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/record_share/:record_share_token/meta"). + Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/")) + default: + return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local") + } + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseURLResolve(runtime) + }, +} + +var BaseTitleResolve = common.Shortcut{ + Service: "base", + Command: "+title-resolve", + Description: "Resolve a Base title or keyword through Drive search", + Risk: "read", + Scopes: []string{"search:docs:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"}, + {Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"}, + {Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"}, + }, + Tips: []string{ + `Example: lark-cli base +title-resolve --title "Sales pipeline"`, + "Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readTitleResolveQuery(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + query, err := readTitleResolveQuery(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/search/v2/doc_wiki/search"). + Body(buildTitleResolveSearchBody(query)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseTitleResolve(runtime) + }, +} + +func readURLResolveInput(runtime *common.RuntimeContext) (string, error) { + urlValue := strings.TrimSpace(runtime.Str("url")) + queryValue := strings.TrimSpace(runtime.Str("query")) + if urlValue != "" && queryValue != "" { + return "", baseFlagErrorf("--url and --query are mutually exclusive") + } + value := urlValue + if value == "" { + value = queryValue + } + if value == "" { + return "", baseFlagErrorf("specify --url") + } + return value, nil +} + +func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) { + values := []struct { + name string + value string + }{ + {"title", strings.TrimSpace(runtime.Str("title"))}, + {"query", strings.TrimSpace(runtime.Str("query"))}, + {"url", strings.TrimSpace(runtime.Str("url"))}, + } + var pickedName, pickedValue string + for _, v := range values { + if v.value == "" { + continue + } + if pickedValue != "" { + return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name) + } + pickedName = v.name + pickedValue = v.value + } + if pickedValue == "" { + return "", baseFlagErrorf("specify --title") + } + if len([]rune(pickedValue)) > titleResolveQueryMaxLen { + return "", resolveValidationError( + fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen), + "Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.", + ) + } + return pickedValue, nil +} + +func executeBaseURLResolve(runtime *common.RuntimeContext) error { + raw, err := readURLResolveInput(runtime) + if err != nil { + return err + } + parsed, err := parseResolveURL(raw) + if err != nil { + return err + } + + switch classifyBaseURL(parsed) { + case "base_url": + out := resolveBaseURL(parsed) + enrichBaseResolveHint(runtime, out) + runtime.OutFormat(out, nil, nil) + return nil + case "wiki_url": + out, err := resolveWikiBaseURL(runtime, parsed) + if err != nil { + return err + } + runtime.OutFormat(out, nil, nil) + return nil + case "record_share_url": + out, err := resolveRecordShareURL(runtime, parsed) + if err != nil { + return err + } + runtime.OutFormat(out, nil, nil) + return nil + case "form_share_url": + runtime.OutFormat(resolveFormShareURL(parsed), nil, nil) + return nil + case "view_share_url": + return resolveValidationError( + "This is a Base view share URL. CLI does not support resolving Base view share URLs.", + "Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.", + ) + case "dashboard_share_url": + return resolveValidationError( + "This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.", + "Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.", + ) + case "workspace_url": + return resolveValidationError( + "This is a Base workspace URL. CLI does not support resolving Base workspace URLs.", + "Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.", + ) + case "add_record_url": + return resolveValidationError( + "This is a Base add-record URL. CLI does not support resolving Base add-record URLs.", + "Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.", + ) + default: + return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric) + } +} + +func parseResolveURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric) + } + return parsed, nil +} + +func classifyBaseURL(u *url.URL) string { + path := normalizeResolvePath(u.Path) + switch { + case pathSegmentExists(path, "/base/workspace/"): + return "workspace_url" + case pathSegmentExists(path, "/base/add/"): + return "add_record_url" + case pathSegmentExists(path, "/base/"): + return "base_url" + case pathSegmentExists(path, "/wiki/"): + return "wiki_url" + case pathSegmentExists(path, "/record/"): + return "record_share_url" + case pathSegmentExists(path, "/share/base/form/"): + return "form_share_url" + case pathSegmentExists(path, "/share/base/view/"): + return "view_share_url" + case pathSegmentExists(path, "/share/base/dashboard/"): + return "dashboard_share_url" + default: + return "" + } +} + +func resolveBaseURL(u *url.URL) map[string]interface{} { + query := u.Query() + out := map[string]interface{}{ + "input_type": "base_url", + "resource_type": "bitable", + "base_token": firstPathSegmentAfter(u.Path, "/base/"), + } + if tableID := strings.TrimSpace(query.Get("table")); tableID != "" { + out["table_id"] = tableID + } + if viewID := strings.TrimSpace(query.Get("view")); viewID != "" { + out["view_id"] = viewID + } + if recordID := strings.TrimSpace(query.Get("record")); recordID != "" { + out["record_id"] = recordID + } + return out +} + +func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) { + token := firstPathSegmentAfter(u.Path, "/wiki/") + data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil) + if err != nil { + return nil, err + } + node := common.GetMap(data, "node") + objType := strings.TrimSpace(common.GetString(node, "obj_type")) + if objType != "bitable" { + return nil, resolveValidationError( + fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)), + "Use the corresponding skill for that resource, or provide a Base URL.", + ) + } + baseToken := strings.TrimSpace(common.GetString(node, "obj_token")) + if baseToken == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token") + } + return map[string]interface{}{ + "input_type": "wiki_url", + "resource_type": "bitable", + "wiki_node_token": token, + "base_token": baseToken, + "title": common.GetString(node, "title"), + "hint": resolveHint("", nil), + }, nil +} + +func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) { + shareToken := firstPathSegmentAfter(u.Path, "/record/") + data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "input_type": "record_share_url", + "resource_type": "bitable", + "record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken), + "base_token": common.GetString(data, "base_token"), + "table_id": common.GetString(data, "table_id"), + "record_id": common.GetString(data, "record_id"), + } + enrichRecordShareResolveHint(runtime, out) + return out, nil +} + +func resolveFormShareURL(u *url.URL) map[string]interface{} { + return map[string]interface{}{ + "input_type": "form_share_url", + "resource_type": "bitable_form", + "share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"), + "hint": map[string]interface{}{ + "next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response", + }, + } +} + +func executeBaseTitleResolve(runtime *common.RuntimeContext) error { + query, err := readTitleResolveQuery(runtime) + if err != nil { + return err + } + data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query)) + if err != nil { + return err + } + candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units")) + switch len(candidates) { + case 0: + return resolveValidationError( + "No Base matched this title or keyword.", + "Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.", + ) + case 1: + out := map[string]interface{}{ + "input_type": "title_query", + "resource_type": "bitable", + "title": candidates[0]["title"], + "base_token": candidates[0]["base_token"], + "url": candidates[0]["url"], + "owner_name": candidates[0]["owner_name"], + "update_time": candidates[0]["update_time"], + "hint": resolveHint("", nil), + } + runtime.OutFormat(out, nil, nil) + return nil + default: + runtime.OutFormat(map[string]interface{}{ + "input_type": "title_query", + "resource_type": "bitable", + "candidates": candidates, + "hint": map[string]interface{}{ + "next_step": baseTitleResolveHint, + }, + }, nil, nil) + return nil + } +} + +func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) { + baseToken := strings.TrimSpace(common.GetString(out, "base_token")) + tableID := strings.TrimSpace(common.GetString(out, "table_id")) + if baseToken == "" || tableID == "" { + out["hint"] = resolveHint("", nil) + return + } + fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100) + if err != nil { + out["hint"] = resolveHint(tableID, nil) + return + } + out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}}) +} + +func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) { + baseToken := strings.TrimSpace(common.GetString(out, "base_token")) + tableID := strings.TrimSpace(common.GetString(out, "table_id")) + recordID := strings.TrimSpace(common.GetString(out, "record_id")) + hint := map[string]interface{}{} + if baseToken != "" && tableID != "" && recordID != "" { + if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil { + hint["record_data"] = formatResolvedRecordData(record) + } + } + if baseToken != "" && tableID != "" { + if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil { + hint["fields"] = map[string]interface{}{"fields": fields, "total": total} + } + } + out["hint"] = resolveHint(tableID, hint) + common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID) +} + +func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) { + body := map[string]interface{}{"record_id_list": []string{recordID}} + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body) + return handleBaseAPIResult(result, err, "batch get records") +} + +func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} { + fieldIDs := common.GetSlice(record, "field_id_list") + fieldNames := common.GetSlice(record, "fields") + rows := common.GetSlice(record, "data") + + data := map[string]interface{}{} + if len(rows) > 0 { + if values, ok := rows[0].([]interface{}); ok { + for i, value := range values { + data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value + } + } + } + return data +} + +func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string { + if index < len(fieldIDs) { + if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" { + return fieldID + } + } + if index < len(fieldNames) { + if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" { + return fieldName + } + } + return fmt.Sprintf("field_%d", index+1) +} + +func recordShareNextStep(baseToken, tableID, recordID string) string { + return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"":""}' to update this record`, baseToken, tableID, recordID) +} + +func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} { + hint := map[string]interface{}{} + for key, value := range extra { + hint[key] = value + } + if strings.TrimSpace(tableID) != "" { + hint["next_step"] = nextStepRecordList + } else { + hint["next_step"] = nextStepBaseBlockList + } + return hint +} + +func buildTitleResolveSearchBody(query string) map[string]interface{} { + filter := map[string]interface{}{"doc_types": []string{"BITABLE"}} + return map[string]interface{}{ + "query": query, + "page_size": 5, + "doc_filter": filter, + "wiki_filter": filter, + } +} + +func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} { + candidates := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + row, _ := item.(map[string]interface{}) + meta, _ := row["result_meta"].(map[string]interface{}) + if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" { + continue + } + token := strings.TrimSpace(common.GetString(meta, "token")) + if token == "" { + continue + } + title := stripSearchHighlight(common.GetString(row, "title_highlighted")) + if title == "" { + title = strings.TrimSpace(common.GetString(row, "title")) + } + candidates = append(candidates, map[string]interface{}{ + "title": title, + "base_token": token, + "url": common.GetString(meta, "url"), + "owner_name": common.GetString(meta, "owner_name"), + "update_time": common.GetString(meta, "update_time_iso"), + }) + } + return candidates +} + +var searchHighlightTagRe = regexp.MustCompile(``) + +func stripSearchHighlight(s string) string { + return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, "")) +} + +func resolveValidationError(message, hint string) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint) +} + +func normalizeResolvePath(path string) string { + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func pathSegmentExists(path, prefix string) bool { + return firstPathSegmentAfter(path, prefix) != "" +} + +func firstPathSegmentAfter(path, prefix string) string { + path = normalizeResolvePath(path) + if !strings.HasPrefix(path, prefix) { + return "" + } + rest := path[len(prefix):] + if idx := strings.IndexByte(rest, '/'); idx >= 0 { + rest = rest[:idx] + } + return strings.TrimSpace(rest) +} + +func valueOrUnknown(s string) string { + if strings.TrimSpace(s) == "" { + return "an unknown resource type" + } + return s +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/shortcuts/base/base_resolve_test.go b/shortcuts/base/base_resolve_test.go new file mode 100644 index 00000000..8b00fc56 --- /dev/null +++ b/shortcuts/base/base_resolve_test.go @@ -0,0 +1,454 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "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 TestBaseURLResolveBaseURL(t *testing.T) { + t.Run("with coordinates", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(fieldListStub("bas123", "tbl123")) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", + "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "base_url" || data["base_token"] != "bas123" { + t.Fatalf("unexpected output: %#v", data) + } + if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" { + t.Fatalf("missing Base coordinates: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + fields, _ := hint["fields"].(map[string]interface{}) + if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) { + t.Fatalf("unexpected hint: %#v", hint) + } + }) + + t.Run("base only", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "base_url" || data["base_token"] != "bas123" { + t.Fatalf("unexpected output: %#v", data) + } + if _, ok := data["table_id"]; ok { + t.Fatalf("table_id should be omitted for base-only URL: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + if hint["next_step"] != nextStepBaseBlockList { + t.Fatalf("unexpected hint: %#v", hint) + } + }) + + t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["base_token"] != "bas123" || data["table_id"] != "tbl123" { + t.Fatalf("unexpected output: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + if hint["next_step"] != nextStepRecordList { + t.Fatalf("unexpected hint: %#v", hint) + } + if _, ok := hint["fields"]; ok { + t.Fatalf("fields should be omitted when enrichment fails: %#v", hint) + } + }) +} + +func TestBaseURLResolveWikiURL(t *testing.T) { + t.Run("bitable", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "bitable", + "obj_token": "bas123", + "title": "Demo Base", + }, + }, + }, + }) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" { + t.Fatalf("unexpected output: %#v", data) + } + }) + + t.Run("non bitable", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"}, + }, + }, + }) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "not Base") { + t.Fatalf("err=%v, want non-Base validation error", err) + } + }) +} + +func TestBaseURLResolveRecordShareURL(t *testing.T) { + t.Run("enriched", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123")) + reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123")) + reg.Register(fieldListStub("bas123", "tbl123")) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" { + t.Fatalf("unexpected output: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + recordData, _ := hint["record_data"].(map[string]interface{}) + fields, _ := hint["fields"].(map[string]interface{}) + nextStep, _ := hint["next_step"].(string) + if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) { + t.Fatalf("unexpected hint: %#v", hint) + } + }) + + t.Run("enrichment failure still returns meta", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123")) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" { + t.Fatalf("unexpected output: %#v", data) + } + hint, _ := data["hint"].(map[string]interface{}) + nextStep, _ := hint["next_step"].(string) + if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") { + t.Fatalf("unexpected hint: %#v", hint) + } + if _, ok := hint["record_data"]; ok { + t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint) + } + if _, ok := hint["fields"]; ok { + t.Fatalf("fields should be omitted when enrichment fails: %#v", hint) + } + }) +} + +func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_share_token": shareToken, + "base_token": baseToken, + "table_id": tableID, + "record_id": recordID, + }, + }, + } +} + +func TestBaseURLResolveFormShareURL(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" { + t.Fatalf("unexpected output: %#v", data) + } +} + +func TestBaseURLResolveValidationErrors(t *testing.T) { + tests := []struct { + name string + rawURL string + wantText string + wantHint string + }{ + {"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"}, + {"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"}, + {"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"}, + {"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"}, + {"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""}, + {"not url", "bas123", "only accepts full URLs", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", tc.rawURL, "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), tc.wantText) { + t.Fatalf("err=%v, want contains %q", err, tc.wantText) + } + p, ok := errs.ProblemOf(err) + if !ok || p.Hint == "" { + t.Fatalf("err=%v, want typed error with hint", err) + } + if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) { + t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint) + } + if strings.Contains(p.Hint, "original /base/{base_token}") { + t.Fatalf("hint should not require original /base URL: %q", p.Hint) + } + }) + } +} + +func TestBaseResolveInputXOR(t *testing.T) { + t.Run("url resolve", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v, want xor validation", err) + } + }) + + t.Run("title resolve", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{ + "+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v, want xor validation", err) + } + }) +} + +func TestBaseResolveHelpFlags(t *testing.T) { + for _, tc := range []struct { + shortcut string + definition common.Shortcut + primaryFlag string + primaryDesc string + aliasFlags []string + }{ + { + shortcut: "+url-resolve", + definition: BaseURLResolve, + primaryFlag: "url", + primaryDesc: "Base/Wiki/record-share URL to resolve", + aliasFlags: []string{"query"}, + }, + { + shortcut: "+title-resolve", + definition: BaseTitleResolve, + primaryFlag: "title", + primaryDesc: "Base title keyword", + aliasFlags: []string{"query", "url"}, + }, + } { + t.Run(tc.shortcut, func(t *testing.T) { + parent := &cobra.Command{Use: "base"} + tc.definition.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + primary := cmd.Flags().Lookup(tc.primaryFlag) + primaryUsage := "" + if primary != nil { + primaryUsage = primary.Usage + } + if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) { + t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage) + } + for _, aliasFlag := range tc.aliasFlags { + alias := cmd.Flags().Lookup(aliasFlag) + if alias == nil || !alias.Hidden { + t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias) + } + } + }) + } +} + +func TestBaseTitleResolve(t *testing.T) { + t.Run("single result", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(titleResolveSearchStub([]interface{}{ + map[string]interface{}{ + "title_highlighted": "Sales Pipeline", + "result_meta": map[string]interface{}{ + "doc_types": "BITABLE", + "token": "bas123", + "url": "https://example.larkoffice.com/base/bas123", + "owner_name": "Alice", + "update_time_iso": "2026-06-09T10:00:00+08:00", + }, + }, + })) + + err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{ + "+title-resolve", "--title", "Pipeline", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" { + t.Fatalf("unexpected output: %#v", data) + } + }) + + t.Run("multiple results and filter non bitable", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(titleResolveSearchStub([]interface{}{ + map[string]interface{}{ + "title_highlighted": "Doc hit", + "result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"}, + }, + map[string]interface{}{ + "title_highlighted": "Base One", + "result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"}, + }, + map[string]interface{}{ + "title_highlighted": "Base Two", + "result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"}, + }, + })) + + err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{ + "+title-resolve", "--url", "Base", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + candidates, _ := data["candidates"].([]interface{}) + if len(candidates) != 2 { + t.Fatalf("candidates=%#v, want 2", data["candidates"]) + } + }) + + t.Run("no results", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(titleResolveSearchStub(nil)) + err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{ + "+title-resolve", "--title", "missing", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "No Base matched") { + t.Fatalf("err=%v, want no result validation", err) + } + }) + + t.Run("query too long", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{ + "+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") { + t.Fatalf("err=%v, want query length validation", err) + } + }) +} + +func titleResolveSearchStub(items []interface{}) *httpmock.Stub { + if items == nil { + items = []interface{}{} + } + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/search/v2/doc_wiki/search", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "res_units": items, + }, + }, + } +} + +func fieldListStub(baseToken, tableID string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "total": 2, + "fields": []interface{}{ + map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"}, + map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"}, + }, + }, + }, + } +} + +func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{recordID}, + "field_id_list": []interface{}{"fld_name", "fld_status"}, + "fields": []interface{}{"Name", "Status"}, + "data": []interface{}{[]interface{}{"Alice", "Done"}}, + }, + }, + } +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index d13e841e..4dd2769c 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -155,6 +155,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) { func TestShortcutsCatalog(t *testing.T) { shortcuts := Shortcuts() want := []string{ + "+url-resolve", "+title-resolve", "+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete", "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", "+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 42fa883a..61b7a8aa 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -8,6 +8,8 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all base shortcuts. func Shortcuts() []common.Shortcut { return []common.Shortcut{ + BaseURLResolve, + BaseTitleResolve, BaseBaseBlockList, BaseBaseBlockCreate, BaseBaseBlockMove, diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index f211dcb5..3399146d 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -31,10 +31,17 @@ metadata: - Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。 - 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL;简单命令由命令自身的参数、tips 和错误恢复承接。 - 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。 -- 用户只给 Base 名称或关键词时,先用 `lark-cli drive +search --query --doc-types bitable` 定位资源。 -- Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。 - 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。 +## 先获取 Base Token 和所需 ID + +进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID;不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`。 + +- 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。 +- 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "" --as user`;`--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。 +- 文档嵌入 Base 标签:直接读取 `` / `` 的 `token` 作为 `--base-token`,`table-id` 作为 `--table-id`,`view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`。 +- 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base;用户要新建时才用 `+base-create`。 + ## 快速路由 | 用户目标 | 优先命令 | 何时读 reference | @@ -113,22 +120,6 @@ metadata: - `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。 - 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。 -## Token 与链接 - -| 输入类型 | 含义 / 正确处理方式 | -|---|---| -| `/base/{token}` | 普通 Base 链接;提取 `/base/` 后的 token 作为 `--base-token` | -| `/wiki/{token}` | Wiki 节点链接;先 `wiki +node-get`,当 `data.obj_type=bitable` 时使用 `data.obj_token` 作为 `--base-token` | -| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id`;`blk` 开头是 dashboard ID;`wkf` 开头是 workflow ID | -| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 | -| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token,走 `+form-detail` / `+form-submit --share-token ` | -| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 | -| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 | -| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token --table-id --record-ids ` | -| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 | - -`wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。 - ## Dashboard / Workflow / Role - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。 @@ -139,7 +130,7 @@ metadata: | 错误 / 现象 | 恢复动作 | |---|---| -| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token | +| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按入口规则重新获取真实 `base_token` | | `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token,不要立刻改走裸 API | | `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID;注意空格、大小写和跨表字段 | | `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |