Compare commits

..

3 Commits

Author SHA1 Message Date
sunpeiyang.996
2487b4e1a6 feat: pass docs selection anchors to fetch 2026-07-08 23:39:30 +08:00
liangshuo-1
cdd9d3409b feat(affordance): usage guidance for shortcuts and per-command skills (#1793) 2026-07-08 19:45:21 +08:00
zhaojunlin0405
06f6b0b18c fix: preserve original filename in multipart file upload (#1767)
* chore: bump oapi-sdk-go/v3 to v3.7.2 for filename-aware multipart upload

* fix: preserve original filename in multipart file upload

BuildFormdata read local files into a bytes.Reader before handing them
to the SDK, so the SDK's part-filename detection (which only reads
*os.File) fell back to "unknown-file" for every local --file upload.
Use AddFileWithName with the file's basename instead.
2026-07-08 19:16:03 +08:00
37 changed files with 1582 additions and 533 deletions

View File

@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,6 +1,42 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.

View File

@@ -4,10 +4,14 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -1069,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -49,9 +49,6 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Short: "Device Flow authorization login",
Long: `Device Flow authorization login.
With no --scope/--domain/--recommend flag, this requests scopes for all known
business domains (equivalent to --domain all); pass --domain or --scope to narrow it.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
@@ -74,7 +71,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmdutil.SetRisk(cmd, "write")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request scopes for all known domains (equivalent to --domain all)")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
@@ -147,6 +144,33 @@ func authLoginRun(opts *LoginOptions) error {
}
selectedDomains := opts.Domains
scopeLevel := "" // "common" or "all" (from interactive mode)
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
}
available := make([]string, 0, len(knownDomains))
for k := range knownDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
}
}
}
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
@@ -154,51 +178,30 @@ func authLoginRun(opts *LoginOptions) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
}
// scopeOnly is the one path that must never touch the domain catalog
// (remote or local): --scope given alone, with neither --domain nor
// --recommend. Every other path — including bare `auth login`, now that
// the interactive picker is gone — needs the legal domain set to resolve
// scopes.
scopeOnly := opts.Scope != "" && !opts.Recommend && len(selectedDomains) == 0
var remote map[string][]string
var remoteOK bool
if !scopeOnly {
// Pull the remote scopes.json once for this login (not cached); any
// read failure (network/timeout/non-2xx/malformed) silently falls back
// to the local full computation — no warning, no telemetry.
remote, remoteOK = larkauth.FetchRemoteScopes(config.Brand)
legalDomains, allLegalDomains := legalDomainsFor(remote, remoteOK, config.Brand)
// Expand --domain all against the resolved legal domain set.
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = allLegalDomains
break
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
if err != nil {
return err
}
}
if len(selectedDomains) > 0 {
// Validate explicitly-supplied domain names and suggest corrections.
for _, d := range selectedDomains {
if !legalDomains[d] {
if suggestion := suggestDomain(d, legalDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
}
available := make([]string, 0, len(legalDomains))
for k := range legalDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
}
selectedDomains = result.Domains
scopeLevel = result.ScopeLevel
} else {
// Bare `auth login` and `--recommend` without `--domain` both span
// the full legal domain set now that the interactive picker and
// the local auto-approve filter are gone (--recommend ≡ --domain all).
selectedDomains = allLegalDomains
log(msg.HintHeader)
log("Common options:")
log(msg.HintCommon1)
log(msg.HintCommon2)
log(msg.HintCommon3)
log(msg.HintCommon4)
log("")
log("View all options:")
log(msg.HintFooter)
log("")
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
}
}
@@ -212,8 +215,19 @@ func authLoginRun(opts *LoginOptions) error {
// --scope, --domain, and --recommend combine additively so callers can,
// for example, request all `docs` scopes plus a few specific `drive`
// scopes in a single command.
if len(selectedDomains) > 0 {
candidateScopes := resolveScopesForDomains(selectedDomains, remote, remoteOK, config.Brand)
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
if opts.Recommend || scopeLevel == "common" {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(candidateScopes) == 0 && opts.Scope == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
@@ -368,10 +382,10 @@ func authLoginRun(opts *LoginOptions) error {
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, result.Token.StatusMessage)
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, result.Token.StatusMessage)
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
@@ -451,10 +465,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
}
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName, result.Token.StatusMessage)
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary, result.Token.StatusMessage)
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
@@ -536,30 +550,6 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
return result
}
// resolveScopesForDomains resolves the scope set for the given domains. When
// the remote scopes.json is available it takes the union of each domain's
// user_scopes from the remote result (remote is authoritative, including
// domains this CLI build doesn't know about locally); otherwise it falls back
// to the local synthesis via collectScopesForDomains. Always returns a
// deduplicated, alphabetically sorted slice.
func resolveScopesForDomains(domains []string, remote map[string][]string, remoteOK bool, brand core.LarkBrand) []string {
if remoteOK {
set := make(map[string]bool)
for _, d := range domains {
for _, s := range remote[d] {
set[s] = true
}
}
out := make([]string, 0, len(set))
for s := range set {
out = append(out, s)
}
sort.Strings(out)
return out
}
return collectScopesForDomains(domains, "user", brand)
}
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
@@ -592,25 +582,6 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
return domains
}
// legalDomainsFor returns the authoritative domain set for this login: the
// remote scopes.json keys when available (a remote-listed domain unknown to
// this CLI build is still legal), otherwise the local known-domain set.
// Returns both a membership set (for --domain validation) and a sorted slice
// (for `all` expansion and the bare-login/--recommend-without-domain default).
func legalDomainsFor(remote map[string][]string, remoteOK bool, brand core.LarkBrand) (map[string]bool, []string) {
if remoteOK {
set := make(map[string]bool, len(remote))
sorted := make([]string, 0, len(remote))
for d := range remote {
set[d] = true
sorted = append(sorted, d)
}
sort.Strings(sorted)
return set, sorted
}
return allKnownDomains(brand), sortedKnownDomains(brand)
}
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
)
// domainMeta describes a domain for the interactive selector.
type domainMeta struct {
Name string
Title string
Description string
}
// interactiveResult holds the user's selections from the interactive form.
type interactiveResult struct {
Domains []string
ScopeLevel string // "common" or "all"
}
// getDomainMetadata returns metadata for all known domains, sorted by name.
func getDomainMetadata(lang string) []domainMeta {
seen := make(map[string]bool)
var domains []domainMeta
// 1. Domains from from_meta projects (skip domains with auth_domain)
for _, project := range registry.ListFromMetaProjects() {
if registry.HasAuthDomain(project) {
seen[project] = true
continue
}
dm := buildDomainMeta(project, lang)
domains = append(domains, dm)
seen[project] = true
}
// 2. Shortcut-only domains
shortcutOnlyNames := getShortcutOnlyDomainNames()
for _, name := range shortcutOnlyNames {
if !seen[name] {
dm := buildDomainMeta(name, lang)
domains = append(domains, dm)
seen[name] = true
}
}
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
// (skip domains with auth_domain — they are folded into their parent)
shortcutOnlySet := make(map[string]bool)
for _, n := range shortcutOnlyNames {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
seen[sc.Service] = true
}
}
sort.Slice(domains, func(i, j int) bool {
return domains[i].Name < domains[j].Name
})
return domains
}
// buildDomainMeta constructs a domainMeta for a given service name and language.
// It reads from the service_descriptions.json config first, falling back to
// from_meta spec fields if not found.
func buildDomainMeta(name, lang string) domainMeta {
title := registry.GetServiceTitle(name, lang)
desc := registry.GetServiceDetailDescription(name, lang)
if title != "" || desc != "" {
return domainMeta{
Name: name,
Title: title,
Description: desc,
}
}
// Fallback: read from the typed service spec (legacy)
dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
}
return dm
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
options := make([]huh.Option[string], len(allDomains))
for i, dm := range allDomains {
var label string
switch {
case dm.Title != "" && dm.Description != "":
label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description)
case dm.Title != "":
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title)
default:
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description)
}
options[i] = huh.NewOption(label, dm.Name)
}
var selectedDomains []string
var permLevel string
// Phase 1a: domain selection
// Phase 1b: permission level (shown after domain selection completes)
form1 := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title(msg.SelectDomains).
Description(msg.DomainHint).
Options(options...).
Value(&selectedDomains).
Validate(func(s []string) error {
if len(s) == 0 {
return fmt.Errorf(msg.ErrNoDomain)
}
return nil
}),
),
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.PermLevel).
Options(
huh.NewOption(msg.PermCommon, "common"),
huh.NewOption(msg.PermAll, "all"),
).
Value(&permLevel),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form1.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if len(selectedDomains) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user", brand)
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}
// Print summary
permLabel := msg.PermAllLabel
if permLevel == "common" {
permLabel = msg.PermCommonLabel
}
fmt.Fprintf(ios.ErrOut, msg.Summary)
fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", "))
fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel)
scopePreview := strings.Join(scopes, ", ")
if len(scopePreview) > 80 {
scopePreview = strings.Join(scopes[:3], ", ") + ", ..."
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,
}, nil
}

View File

@@ -6,6 +6,21 @@ package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
DomainHint string
PermLevel string
PermCommon string
PermAll string
Summary string
SummaryDomains string
SummaryPerm string
SummaryScopes string
PermAllLabel string
PermCommonLabel string
ErrNoDomain string
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
@@ -19,9 +34,31 @@ type loginMsg struct {
NewlyGrantedScopes string
NoScopes string
StatusHint string
// Non-interactive hint (no flags)
HintHeader string
HintCommon1 string
HintCommon2 string
HintCommon3 string
HintCommon4 string
HintFooter string
}
var loginMsgZh = &loginMsg{
SelectDomains: "选择要授权的业务域",
DomainHint: "空格=选择, 回车=确认",
PermLevel: "权限类型",
PermCommon: "常用权限",
PermAll: "全部权限",
Summary: "\n摘要:\n",
SummaryDomains: " 域: %s\n",
SummaryPerm: " 权限: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "全部权限",
PermCommonLabel: "常用权限",
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点)。",
@@ -34,9 +71,30 @@ var loginMsgZh = &loginMsg{
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
HintCommon2: " --domain all 授权所有已知域的权限",
HintCommon3: " --domain calendar,task 授权日历和任务域的权限",
HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限",
HintFooter: " lark-cli auth login --help",
}
var loginMsgEn = &loginMsg{
SelectDomains: "Select domains to authorize",
DomainHint: "Space=toggle, Enter=confirm",
PermLevel: "Permission level",
PermCommon: "Common scopes",
PermAll: "All scopes",
Summary: "\nSummary:\n",
SummaryDomains: " Domains: %s\n",
SummaryPerm: " Level: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "All scopes",
PermCommonLabel: "Common scopes",
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
@@ -49,6 +107,13 @@ var loginMsgEn = &loginMsg{
NewlyGrantedScopes: " Newly granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",
HintCommon2: " --domain all authorize all known domain scopes",
HintCommon3: " --domain calendar,task authorize calendar and task scopes",
HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes",
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
@@ -58,3 +123,10 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
}
return loginMsgZh
}
// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -17,8 +17,8 @@ func TestGetLoginMsg_Zh(t *testing.T) {
if msg != loginMsgZh {
t.Error("expected zh message set")
}
if msg.OpenURL != "在浏览器中打开以下链接进行认证:\n\n" {
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
if msg.SelectDomains != "选择要授权的业务域" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
}
}
@@ -27,8 +27,8 @@ func TestGetLoginMsg_En(t *testing.T) {
if msg != loginMsgEn {
t.Error("expected en message set")
}
if msg.OpenURL != "Open this URL in your browser to authenticate:\n\n" {
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
if msg.SelectDomains != "Select domains to authorize" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
}
}
@@ -77,6 +77,24 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
if got == msg.AuthorizedUser {
t.Errorf("%s AuthorizedUser has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {
t.Errorf("%s SummaryDomains has no format verb", lang)
}
// SummaryPerm should contain %s
got = fmt.Sprintf(msg.SummaryPerm, "all")
if got == msg.SummaryPerm {
t.Errorf("%s SummaryPerm has no format verb", lang)
}
// SummaryScopes should contain %d and %s
got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c")
if got == msg.SummaryScopes {
t.Errorf("%s SummaryScopes has no format verb", lang)
}
}
}

View File

@@ -140,15 +140,13 @@ func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary
}
// writeLoginSuccess emits the successful login payload in either JSON or text
// format together with the computed scope breakdown. statusMessage is the
// authorization response's status_message text (e.g. pending-approval),
// passed through verbatim into the JSON payload; text mode does not render it.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary, statusMessage string) {
// format together with the computed scope breakdown.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
if opts.JSON {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil, statusMessage))
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
fmt.Fprintln(f.IOStreams.Out, string(b))
return
}
@@ -163,17 +161,14 @@ func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, op
// handleLoginScopeIssue prints or returns a structured missing-scope result
// while preserving a successful login outcome when authorization completed.
// statusMessage is the authorization response's status_message text, passed
// through into the JSON payload when authorization actually succeeded
// (partial grant); it is unused on the failed-login path.
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName, statusMessage string) error {
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
if issue == nil {
return nil
}
loginSucceeded := openId != ""
if opts.JSON {
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue, statusMessage))
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return output.ErrBare(output.ExitAuth)
}
@@ -203,13 +198,7 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
// authorizationCompletePayload builds the JSON payload for a completed login,
// optionally attaching a warning when requested scopes are missing.
// statusMessage is the authorization response's status_message text (e.g.
// "user hasn't chosen yet" / "tenant doesn't allow this" / "pending
// approval"), passed through verbatim — the CLI does not parse, classify, or
// truncate it. The key is always present; an empty string means
// the upstream response carried no message, matching the stable-shape
// convention used by the other summary fields above.
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue, statusMessage string) map[string]interface{} {
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
if summary == nil {
summary = &loginScopeSummary{}
}
@@ -223,7 +212,6 @@ func authorizationCompletePayload(openId, userName string, summary *loginScopeSu
"already_granted": emptyIfNil(summary.AlreadyGranted),
"missing": emptyIfNil(summary.Missing),
"granted": emptyIfNil(summary.Granted),
"status_message": statusMessage,
}
if issue != nil {
payload["warning"] = map[string]interface{}{

View File

@@ -40,7 +40,6 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
},
"", // openId empty -> loginSucceeded = false
"tester",
"", // statusMessage unused on the failed-login path
)
if err == nil {
@@ -60,24 +59,3 @@ func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
}
}
// TestAuthorizationCompletePayload_StatusMessage asserts that the
// authorization response's status_message text is passed through into the
// JSON payload verbatim (CLI does not parse/classify/truncate it), and
// that the field is always present, using an empty string when there is no
// message, consistent with the stable-output-shape convention already used
// by "scope" and the other summary fields.
func TestAuthorizationCompletePayload_StatusMessage(t *testing.T) {
summary := &loginScopeSummary{Granted: []string{"a:b:c"}}
p := authorizationCompletePayload("ou_x", "u", summary, nil, "审批中,请等待管理员处理")
if p["status_message"] != "审批中,请等待管理员处理" {
t.Fatalf("status_message = %v", p["status_message"])
}
// No message from upstream -> stable empty string, not an absent key.
p2 := authorizationCompletePayload("ou_x", "u", summary, nil, "")
if p2["status_message"] != "" {
t.Fatalf("empty status_message should be \"\", got %v", p2["status_message"])
}
}

View File

@@ -9,7 +9,7 @@ import (
"errors"
"io"
"net/http"
"reflect"
"slices"
"sort"
"strings"
"testing"
@@ -202,6 +202,25 @@ func TestSortedKnownDomains(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
for _, name := range getShortcutOnlyDomainNames() {
zhDesc := registry.GetServiceDescription(name, "zh")
enDesc := registry.GetServiceDescription(name, "en")
if zhDesc == "" {
t.Errorf("missing zh description for shortcut-only domain %q", name)
}
if enDesc == "" {
t.Errorf("missing en description for shortcut-only domain %q", name)
}
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
@@ -241,81 +260,74 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
}
}
func TestResolveScopesForDomains_RemoteUsed(t *testing.T) {
remote := map[string][]string{
"im": {"im:message:send", "im:chat:read"},
"docs": {"docs:doc:read"},
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
}
got := resolveScopesForDomains([]string{"im"}, remote, true, core.BrandFeishu)
want := []string{"im:chat:read", "im:message:send"} // deduped, ascending by sort.Strings
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveScopesForDomains_UnionAcrossDomains(t *testing.T) {
remote := map[string][]string{
"im": {"im:message:send"},
"docs": {"docs:doc:read"},
}
got := resolveScopesForDomains([]string{"im", "docs"}, remote, true, core.BrandFeishu)
want := []string{"docs:doc:read", "im:message:send"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveScopesForDomains_FallbackToLocal(t *testing.T) {
// remoteOK=false -> falls back to local collectScopesForDomains; im must yield non-empty local scopes
got := resolveScopesForDomains([]string{"im"}, nil, false, core.BrandFeishu)
if len(got) == 0 {
t.Fatal("fallback should return non-empty local scopes for im")
}
}
func TestLegalDomainsFor_RemoteUsed(t *testing.T) {
// includes "newbiz", a domain unknown to this CLI build, verifying a remote-listed domain is still legal
remote := map[string][]string{
"im": {"im:message:send"},
"docs": {"docs:doc:read"},
"newbiz": {"newbiz:thing:read"},
}
set, sorted := legalDomainsFor(remote, true, core.BrandFeishu)
wantSorted := []string{"docs", "im", "newbiz"} // remote keys, ascending by sort.Strings
if !reflect.DeepEqual(sorted, wantSorted) {
t.Fatalf("sorted = %v, want %v", sorted, wantSorted)
}
if len(set) != len(wantSorted) {
t.Fatalf("set size = %d, want %d", len(set), len(wantSorted))
}
for _, d := range wantSorted {
if !set[d] {
t.Errorf("set missing domain %q", d)
// from_meta projects must be present
for _, p := range registry.ListFromMetaProjects() {
if !nameSet[p] {
t.Errorf("from_meta project %q missing from getDomainMetadata", p)
}
}
if !set["newbiz"] {
t.Error("remote-listed domain unknown to this build should still be legal")
}
func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
}
for _, name := range getShortcutOnlyDomainNames() {
if !nameSet[name] {
t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name)
}
}
}
func TestLegalDomainsFor_FallbackToLocal(t *testing.T) {
// remoteOK=false -> falls back to local allKnownDomains/sortedKnownDomains
set, sorted := legalDomainsFor(nil, false, core.BrandFeishu)
if len(sorted) == 0 {
t.Fatal("fallback should return non-empty local domain slice")
}
// set and sorted are two views of the same local domain set and must correspond
if len(set) != len(sorted) {
t.Fatalf("set size %d != sorted size %d", len(set), len(sorted))
}
for _, d := range sorted {
if !set[d] {
t.Errorf("set missing local domain %q", d)
func TestGetDomainMetadata_Sorted(t *testing.T) {
domains := getDomainMetadata("zh")
for i := 1; i < len(domains); i++ {
if domains[i].Name < domains[i-1].Name {
t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name)
}
}
// fallback adopts the local sort order directly, which must equal sortedKnownDomains
if want := sortedKnownDomains(core.BrandFeishu); !reflect.DeepEqual(sorted, want) {
t.Fatalf("sorted = %v, want sortedKnownDomains %v", sorted, want)
}
func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Title == "" {
t.Errorf("domain %q has empty Title", dm.Name)
}
}
}
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
// TestFactory has IsTerminal=false by default
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
err := authLoginRun(opts)
if err == nil {
t.Fatal("expected error for non-terminal without flags")
}
// Should mention specifying scopes
msg := err.Error()
if !strings.Contains(msg, "scopes") {
t.Errorf("expected error to mention scopes, got: %s", msg)
}
// Stderr should explain the split-flow path for non-streaming agents.
stderrStr := stderr.String()
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
if !strings.Contains(stderrStr, want) {
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
}
}
}
@@ -364,7 +376,7 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
}, "")
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
@@ -394,7 +406,7 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester", "")
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -436,7 +448,7 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester", "")
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -468,7 +480,7 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Granted: []string{"offline_access"},
}, "")
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
@@ -553,7 +565,7 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary, "")
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
got := stderr.String()
for _, want := range tt.expectedPresent {
@@ -807,7 +819,7 @@ func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScope
Requested: []string{"im:message:send"},
NewlyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send"},
}, "")
})
got := stderr.String()
for _, want := range []string{
@@ -1155,6 +1167,15 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "event" {
t.Error("event should not appear in interactive domain list")
}
}
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains("")
if domains["whiteboard"] {
@@ -1179,3 +1200,12 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
}
}
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "whiteboard" {
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
}
}
}

View File

@@ -679,7 +679,11 @@ func installTipsHelpFunc(root *cobra.Command) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}

View File

@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
// hand-authored Long when present, else the Short.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return captureHelpBase(cmd, domainBaseAnnotation)
}
// captureHelpBase records a command's pristine lead text once — its
// hand-authored Long, or Short when Long is empty — into the given annotation,
// so lazy re-renders compose onto the original text instead of onto an
// already-augmented Long. This is what lets a shortcut's PostMount-authored
// Long survive: it becomes the base the affordance block is appended below.
func captureHelpBase(cmd *cobra.Command, key string) string {
if base, ok := cmd.Annotations[key]; ok {
return base
}
base := cmd.Long
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
cmd.Annotations[key] = base
return base
}
@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
shortcutBaseAnnotation = "affordance-shortcut-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmdmeta.SetAffordanceRef(cmd, service, methodID)
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
// pointers: each is emitted only when it resolves in the skill tree (see
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
writeRisk(&b, cmd)
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
skills = a.Skills
}
}
@@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
// the overlay declares none; when the overlay has tips, the Go tips are dropped
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
a.Tips = cmdutil.GetTips(cmd)
}
var b strings.Builder
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
writeRisk(&b, cmd)
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
level, ok := cmdutil.GetRisk(cmd)
if !ok {
return
}
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(b, "\n\nRisk: %s", level)
}
}
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
}
}
if len(avail) == 0 {
return
}
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
for _, s := range avail {
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
}
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
if !ok {
return nil, false
}
return affordanceLookup(service, methodID)
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
if !ok {
return ""
}
return renderAffordanceValue(a)
}
// renderAffordanceValue renders an already-parsed affordance. Split from
// renderAffordance so callers can render a value they have adjusted first (e.g.
// a shortcut folding its declarative tips into an overlay that has none).
func renderAffordanceValue(a meta.Affordance) string {
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
@@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
}
}
@@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) {
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
if !PrepareMethodHelp(cmd, nil) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
// non-shortcut commands) for the default help path.
func TestPrepareShortcutHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service == "calendar" && methodID == "+create" {
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
}
return nil, false
}
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
cmdutil.SetRisk(sc, "write")
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
if !strings.Contains(sc.Long, want) {
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
}
}
if strings.Contains(sc.Long, "Full parameter schema:") {
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
}
// No overlay entry -> leave it for the default help path.
bare := &cobra.Command{Use: "+bare", Short: "x"}
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
if PrepareShortcutHelp(bare, nil) {
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
}
// Non-shortcut source is ignored even with a ref.
notSc := &cobra.Command{Use: "create", Short: "x"}
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
if PrepareShortcutHelp(notSc, nil) {
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.
func TestRelatedSkillsStatGating(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
}
skillFS := fstest.MapFS{
"lark-real/SKILL.md": {Data: []byte("# real")},
"lark-real/references/deep.md": {Data: []byte("# deep")},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "lark-typo") {
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
}
// A name/relpath reference to an existing file renders; a missing one drops.
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "references/missing.md") {
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
}
// nil skill FS: the whole Related-skills block is suppressed.
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
PrepareMethodHelp(bare, nil)
if strings.Contains(bare.Long, "Related skills") {
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
}
const authored = "Custom docs help. AI agents MUST read the skill first."
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
if !strings.HasPrefix(sc.Long, authored) {
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
}
if !strings.Contains(sc.Long, "When to use:") {
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
}
// Re-render must reuse the captured base, not append the block twice.
PrepareShortcutHelp(sc, nil)
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {

View File

@@ -4,10 +4,14 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
@@ -1132,6 +1136,63 @@ func TestDetectFileFields(t *testing.T) {
}
}
// parseMultipartFilenames drives one service-method --file upload through the
// mock transport and returns a map of field name -> part filename parsed from
// the captured multipart body. Mirrors cmd/api's helper of the same name
// (inlined here rather than shared, since the two live in different packages)
// to give BuildFormdata's shared local-file fix a second real entry-point
// covering it.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
}
}
return filenames
}
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/im/v1/images",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
}
reg.Register(stub)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames := parseMultipartFilenames(t, stub)
if got := filenames["image"]; got != "photo.jpg" {
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1

4
go.sum
View File

@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -83,10 +83,9 @@ func commandFormResolver(service string) func(string) string {
}
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
return headingToKey(h) // one home for the shortcut/method key convention
}
}

View File

@@ -7,6 +7,8 @@ import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
@@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -19,6 +19,7 @@ import (
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
}
type mdSection struct {
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
}
out[curKey] = a
}
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
pending = ""
}
continue

View File

@@ -34,7 +34,6 @@ type DeviceFlowTokenData struct {
ExpiresIn int
RefreshExpiresIn int
Scope string
StatusMessage string // authorization result text from the response's status_message (e.g. pending-approval); passed through verbatim, not parsed
}
// DeviceFlowResult is the result of polling the token endpoint.
@@ -223,7 +222,6 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
ExpiresIn: tokenExpiresIn,
RefreshExpiresIn: refreshExpiresIn,
Scope: getStr(data, "scope"),
StatusMessage: getStr(data, "status_message"),
},
}
}

View File

@@ -216,34 +216,3 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}
// TestPollDeviceToken_SuccessIncludesStatusMessage asserts that the success
// branch reads the token response's status_message field verbatim into
// DeviceFlowTokenData.StatusMessage. The CLI is a pure passthrough here — no
// parsing/classification of the text.
func TestPollDeviceToken_SuccessIncludesStatusMessage(t *testing.T) {
t.Parallel()
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "test-token",
"refresh_token": "test-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "a:b:c",
"status_message": "审批中,请等待管理员处理",
},
})
result := PollDeviceToken(context.Background(), httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 10, nil)
if result == nil || !result.OK || result.Token == nil {
t.Fatalf("PollDeviceToken() = %+v, want OK with a token", result)
}
if result.Token.StatusMessage != "审批中,请等待管理员处理" {
t.Fatalf("StatusMessage = %q, want the approval-pending text", result.Token.StatusMessage)
}
}

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
)
const (
remoteScopesPath = "/lark-cli/apis/scopes.json"
remoteScopesTimeout = 1 * time.Second
maxRemoteScopesSize = 10 * 1024 * 1024 // 10MB, aligned with internal/registry/remote.go
)
// remoteScopesURLForTest is the injection seam for unit tests to point at an
// httptest server. It is empty at runtime, where the brand-hardcoded production
// URL is used, and must never carry an internal / non-production domain.
var remoteScopesURLForTest string
type remoteScopesFile struct {
Scopes map[string]remoteDomainScopes `json:"scopes"`
}
type remoteDomainScopes struct {
UserScopes []string `json:"user_scopes"`
TenantScopes []string `json:"tenant_scopes"`
}
func remoteScopesURL(brand core.LarkBrand) string {
if remoteScopesURLForTest != "" {
return remoteScopesURLForTest
}
return core.ResolveOpenBaseURL(brand) + remoteScopesPath
}
// FetchRemoteScopes fetches and binary-validates the remote scopes.json.
// It returns (domain -> user_scopes, true) when the whole file is usable, or
// (nil, false) when the caller should fall back to the local set. Any failure
// (network / timeout / non-2xx / empty / bad JSON / structure mismatch /
// malformed scope) returns (nil, false) silently — no warning, no telemetry.
func FetchRemoteScopes(brand core.LarkBrand) (map[string][]string, bool) {
client := transport.NewHTTPClient(remoteScopesTimeout)
req, err := http.NewRequest(http.MethodGet, remoteScopesURL(brand), nil)
if err != nil {
return nil, false
}
resp, err := client.Do(req)
if err != nil {
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteScopesSize))
if err != nil || len(body) == 0 {
return nil, false
}
var file remoteScopesFile
if err := json.Unmarshal(body, &file); err != nil {
return nil, false
}
return validateRemoteScopes(file)
}
func validateRemoteScopes(file remoteScopesFile) (map[string][]string, bool) {
if len(file.Scopes) == 0 {
return nil, false
}
result := make(map[string][]string, len(file.Scopes))
for domain, ds := range file.Scopes {
if ds.UserScopes == nil { // missing user_scopes field / null → whole file untrusted
return nil, false
}
for _, s := range ds.UserScopes {
if !isValidScopeFormat(s) {
return nil, false
}
}
result[domain] = ds.UserScopes
}
return result, true
}
// isValidScopeFormat checks the service:resource:action shape: exactly three
// ":"-separated segments, each non-empty. The resource segment may contain "."
// (e.g. vc:meeting.meetingevent:read). i18n / tenant / version are not checked.
func isValidScopeFormat(s string) bool {
parts := strings.Split(s, ":")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
}
return true
}

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
)
func withRemoteScopesURL(t *testing.T, url string) {
t.Helper()
prev := remoteScopesURLForTest
remoteScopesURLForTest = url
t.Cleanup(func() { remoteScopesURLForTest = prev })
}
func TestFetchRemoteScopes(t *testing.T) {
cases := []struct {
name string
status int
body string
wantOK bool
wantDom string
wantLen int
}{
{
name: "whole usable returns all user_scopes incl unknown domain",
status: 200,
body: `{"version":"1.5.3","scopes":{"bitable":{"i18n_name":{"zh_cn":"多维表格"},"user_scopes":["base:app:copy","base:app:create"],"tenant_scopes":["base:app:copy"]},"brandnewdomain":{"user_scopes":["newsvc:res:read"]}}}`,
wantOK: true,
wantDom: "brandnewdomain",
wantLen: 1,
},
{name: "missing scopes key falls back", status: 200, body: `{"version":"1"}`, wantOK: false},
{name: "empty scopes falls back", status: 200, body: `{"scopes":{}}`, wantOK: false},
{name: "domain missing user_scopes falls back", status: 200, body: `{"scopes":{"im":{"i18n_name":{"zh_cn":"消息"}}}}`, wantOK: false},
{name: "malformed scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message"]}}}`, wantOK: false},
{name: "non-2xx falls back", status: 500, body: `{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`, wantOK: false},
{name: "empty body falls back", status: 200, body: ``, wantOK: false},
{name: "bad json falls back", status: 200, body: `{not json`, wantOK: false},
{name: "i18n/tenant missing still usable", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message:send_as_bot","vc:meeting.meetingevent:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 2},
{name: "empty user_scopes array is usable", status: 200, body: `{"scopes":{"im":{"user_scopes":[]}}}`, wantOK: true, wantDom: "im", wantLen: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(srv.Close)
withRemoteScopesURL(t, srv.URL)
got, ok := FetchRemoteScopes(core.BrandFeishu)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if tc.wantOK {
if _, exists := got[tc.wantDom]; !exists {
t.Fatalf("domain %q missing in result %v", tc.wantDom, got)
}
if len(got[tc.wantDom]) != tc.wantLen {
t.Fatalf("len(%s) = %d, want %d", tc.wantDom, len(got[tc.wantDom]), tc.wantLen)
}
}
})
}
}
func TestFetchRemoteScopesTimeoutFallsBack(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(remoteScopesTimeout + 500*time.Millisecond)
_, _ = w.Write([]byte(`{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`))
}))
t.Cleanup(srv.Close)
withRemoteScopesURL(t, srv.URL)
_, ok := FetchRemoteScopes(core.BrandFeishu)
if ok {
t.Fatal("expected fallback (ok=false) on timeout")
}
}
func TestRemoteScopesURLByBrand(t *testing.T) {
// With an empty seam the production URL is used: core.ResolveOpenBaseURL(brand) + path
remoteScopesURLForTest = ""
feishu := remoteScopesURL(core.BrandFeishu)
lark := remoteScopesURL(core.BrandLark)
if !strings.HasSuffix(feishu, "/lark-cli/apis/scopes.json") || !strings.Contains(feishu, "open.feishu.cn") {
t.Fatalf("feishu url unexpected: %s", feishu)
}
if !strings.Contains(lark, "open.larksuite.com") {
t.Fatalf("lark url unexpected: %s", lark)
}
}

View File

@@ -2,9 +2,11 @@
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// policy engine and the hook selector both consume. It wraps the existing
// cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
// policy engine, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
//
// Three axes:
//
@@ -51,6 +53,12 @@ const (
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// Meta groups the three command-level metadata axes consumed by the policy
@@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
WithParam("--file").
WithCause(err)
}
fd.AddFile(fieldName, bytes.NewReader(data))
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.

View File

@@ -8,8 +8,11 @@ import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
// each against the embedded skill tree (a name → its SKILL.md, a reference →
// that path) and drops any that do not resolve.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`

View File

@@ -165,6 +165,9 @@ const DefaultScopeScore = 0
var cachedScopePriorities map[string]int
var cachedAutoApproveSet map[string]bool
var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only
var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only
var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only
// scopePriorityEntry is used to parse scope_priorities.json entries.
type scopePriorityEntry struct {
@@ -261,6 +264,90 @@ func LoadAutoApproveSet() map[string]bool {
return cachedAutoApproveSet
}
// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform
// (from scope_priorities.json only, before overrides).
func LoadPlatformAutoApproveSet() map[string]bool {
if cachedPlatformAutoApprove != nil {
return cachedPlatformAutoApprove
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil {
var entries []scopePriorityEntry
if json.Unmarshal(data, &entries) == nil {
for _, entry := range entries {
if entry.Recommend == "true" {
m[entry.ScopeName] = true
}
}
}
}
cachedPlatformAutoApprove = m
return cachedPlatformAutoApprove
}
// LoadOverrideAutoApproveAllow returns scopes explicitly listed in
// scope_overrides.json recommend.allow (our desired additions).
func LoadOverrideAutoApproveAllow() map[string]bool {
if cachedOverrideAutoAllow != nil {
return cachedOverrideAutoAllow
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Allow []string `json:"allow"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Allow {
m[s] = true
}
}
}
cachedOverrideAutoAllow = m
return cachedOverrideAutoAllow
}
// LoadOverrideAutoApproveDeny returns scopes explicitly listed in
// scope_overrides.json recommend.deny
func LoadOverrideAutoApproveDeny() map[string]bool {
if cachedOverrideAutoDeny != nil {
return cachedOverrideAutoDeny
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Deny []string `json:"deny"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Deny {
m[s] = true
}
}
}
cachedOverrideAutoDeny = m
return cachedOverrideAutoDeny
}
// IsAutoApproveScope returns true if the scope has AutoApprove rule.
func IsAutoApproveScope(scope string) bool {
return LoadAutoApproveSet()[scope]
}
// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes.
func FilterAutoApproveScopes(scopes []string) []string {
autoApprove := LoadAutoApproveSet()
var result []string
for _, s := range scopes {
if autoApprove[s] {
result = append(result, s)
}
}
return result
}
// GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found.
func GetScopeScore(scope string) int {
priorities := LoadScopePriorities()

View File

@@ -235,6 +235,83 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Logf("Auto-approve set has %d scopes", len(aaSet))
}
func TestLoadPlatformAutoApproveSet(t *testing.T) {
paaSet := LoadPlatformAutoApproveSet()
// This should only include scopes from scope_priorities.json with AutoApprove rule.
// It does NOT apply deny overrides.
if len(paaSet) == 0 {
t.Fatal("expected non-empty platform auto-approve set")
}
t.Logf("Platform auto-approve set has %d scopes", len(paaSet))
}
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}
func TestLoadOverrideAutoApproveDeny(t *testing.T) {
denySet := LoadOverrideAutoApproveDeny()
// deny list may be empty if all entries are moved to _deny (commented out)
t.Logf("Override deny set has %d scopes", len(denySet))
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
}
// Completely unknown scope
if IsAutoApproveScope("zzz:unknown:scope") {
t.Error("expected unknown scope to NOT be auto-approve")
}
}
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
}
result := FilterAutoApproveScopes(scopes)
if len(result) < 1 {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that sheets:spreadsheet:read is included
found := false
for _, s := range result {
if s == "sheets:spreadsheet:read" {
found = true
}
// Ensure unknown scopes are not included
if s == "zzz:unknown:scope" {
t.Error("unknown scope should not be in auto-approve result")
}
}
if !found {
t.Error("expected sheets:spreadsheet:read in result")
}
}
func TestFilterAutoApproveScopes_Empty(t *testing.T) {
result := FilterAutoApproveScopes(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
result = FilterAutoApproveScopes([]string{})
if result != nil {
t.Errorf("expected nil for empty input, got %v", result)
}
}
// --- Helper functions ---
func TestGetRegistryDir(t *testing.T) {

View File

@@ -40,6 +40,9 @@ func resetInit() {
cachedAllScopes = nil
cachedScopePriorities = nil
cachedAutoApproveSet = nil
cachedPlatformAutoApprove = nil
cachedOverrideAutoAllow = nil
cachedOverrideAutoDeny = nil
refreshOnce = sync.Once{}
configuredBrand = ""
enableRemoteMeta = true // tests exercise remote logic

View File

@@ -58,6 +58,26 @@ func GetServiceDescription(name, lang string) string {
return loc.Description
}
// GetServiceTitle returns the localized title for a service domain.
// Returns empty string if not found.
func GetServiceTitle(name, lang string) string {
loc := getServiceLocale(name, lang)
if loc == nil {
return ""
}
return loc.Title
}
// GetServiceDetailDescription returns the localized detail description for a service domain.
// Returns empty string if not found.
func GetServiceDetailDescription(name, lang string) string {
loc := getServiceLocale(name, lang)
if loc == nil {
return ""
}
return loc.Description
}
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
// When auth_domain is set, the service's scopes are collected under the
// parent domain during auth login.

View File

@@ -889,6 +889,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
}
}
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)

View File

@@ -150,12 +150,10 @@ var ContactSearchUser = common.Shortcut{
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)

View File

@@ -24,8 +24,8 @@ func v2FetchFlags() []common.Flag {
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
{Name: "start-block-id", Desc: "range/section anchor block id; range also accepts #share-xxx/#part-xxx selection anchors"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end; selection anchors are not supported"},
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
@@ -151,12 +151,12 @@ func resolveFetchLang(runtime *common.RuntimeContext) string {
// buildReadOption 拼装 read_option JSONfull/空模式返回 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,77 @@ 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 != "" {
if anchor, ok, _ := parseFetchSelectionAnchor(v, "--start-block-id"); ok {
return anchor
}
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, error) {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return "", nil
}
anchor, ok, err := parseFetchSelectionAnchor(ref.Fragment, "--doc")
if err != nil || !ok {
return "", err
}
return anchor, nil
}
func parseFetchSelectionAnchor(raw, param string) (string, bool, error) {
value := strings.TrimSpace(raw)
value = strings.TrimPrefix(value, "#")
for _, prefix := range []string{"share-", "part-"} {
if !strings.HasPrefix(value, prefix) {
continue
}
anchorID := strings.TrimSpace(strings.TrimPrefix(value, prefix))
if anchorID == "" {
return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "selection anchor id is required after %s", prefix).WithParam(param)
}
return prefix + anchorID, true, nil
}
return "", false, nil
}
// 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 +279,10 @@ 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 err := validateFetchSelectionAnchorUsage(runtime, mode); err != nil {
return err
}
if mode == "" || mode == "full" {
return nil
}
@@ -227,7 +301,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"},
@@ -249,3 +323,42 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope")
}
}
func validateFetchSelectionAnchorUsage(runtime *common.RuntimeContext, mode string) error {
startBlockID := strings.TrimSpace(runtime.Str("start-block-id"))
endBlockID := strings.TrimSpace(runtime.Str("end-block-id"))
startAnchor, startIsAnchor, err := parseFetchSelectionAnchor(startBlockID, "--start-block-id")
if err != nil {
return err
}
_, endIsAnchor, err := parseFetchSelectionAnchor(endBlockID, "--end-block-id")
if err != nil {
return err
}
if endIsAnchor {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-block-id does not support selection anchors; pass #share/#part through --start-block-id with --scope range").WithParam("--end-block-id")
}
if !startIsAnchor {
_, _, err := parseFetchSelectionAnchorFromDoc(runtime)
return err
}
if mode != "range" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-block-id selection anchor %q requires --scope range", startAnchor).WithParam("--start-block-id")
}
if endBlockID != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-block-id selection anchor %q cannot be combined with --end-block-id", startAnchor).WithParams(
errs.InvalidParam{Name: "--start-block-id", Reason: "selection anchors define the complete selected range"},
errs.InvalidParam{Name: "--end-block-id", Reason: "remove --end-block-id when --start-block-id is a selection anchor"},
)
}
return nil
}
func parseFetchSelectionAnchorFromDoc(runtime *common.RuntimeContext) (string, bool, error) {
ref, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return "", false, nil
}
return parseFetchSelectionAnchor(ref.Fragment, "--doc")
}

View File

@@ -180,6 +180,63 @@ 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 TestBuildReadOptionNormalizesExplicitSelectionAnchorStart(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "scope", "range")
mustSetFetchFlag(t, runtime, "start-block-id", "#part-CUE3d6Ykno2fkexEvt8cGF8Wnse")
want := map[string]interface{}{
"read_mode": "range",
"start_block_id": "part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
}
if got := buildReadOption(runtime); !reflect.DeepEqual(got, want) {
t.Fatalf("buildReadOption() = %#v, want %#v", got, want)
}
}
func TestBuildReadOptionModes(t *testing.T) {
t.Parallel()
@@ -321,6 +378,31 @@ func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
},
wantParam: "--keyword",
},
{
name: "selection anchor cannot be end block",
setFlags: map[string]string{
"scope": "range",
"end-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
wantParam: "--end-block-id",
},
{
name: "selection anchor start cannot combine with end block",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"end-block-id": "blk_end",
},
wantParams: []string{"--start-block-id", "--end-block-id"},
},
{
name: "selection anchor start requires range",
setFlags: map[string]string{
"scope": "section",
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
wantParam: "--start-block-id",
},
{
name: "section needs start block",
setFlags: map[string]string{
@@ -375,6 +457,19 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
"end-block-id": "blk_end",
},
},
{
name: "range with selection anchor start",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
},
},
{
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 +979,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"), "")

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -43,6 +43,35 @@ 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 setDocsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())