Compare commits

..

4 Commits

Author SHA1 Message Date
zhaoyukun.yk
a2f4f56520 fix: route outbound domains through the endpoint resolver
Consolidate every outbound domain onto the single endpoint resolver so a
Lark-brand user reaches Lark hosts consistently. The skills index and app
registration previously pointed at Feishu regardless of the configured brand;
they now follow it, while the default Feishu brand is unchanged.

Version checks respect the user's configured npm registry instead of assuming
the public one, and a lint guard prevents outbound hosts from being hardcoded
outside the resolver again.
2026-07-09 19:20:06 +08:00
YH-1600
519a600b62 docs: register knowledge organize workflow (#1828) 2026-07-09 18:15:24 +08:00
zhanghuanxu
d87d9b458a docs: require native charts in slide planning 2026-07-09 16:42:45 +08:00
zhanghuanxu
1173179b10 feat(slides): add slides chart demo reference 2026-07-09 16:42:45 +08:00
35 changed files with 558 additions and 170 deletions

View File

@@ -916,25 +916,6 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
}
}
func TestNormalizeBrand(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "feishu"},
{"feishu", "feishu"},
{"lark", "lark"},
{"LARK", "lark"},
{" lark ", "lark"},
{"Lark", "lark"},
}
for _, tt := range tests {
if got := normalizeBrand(tt.input); got != tt.want {
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
custom := filepath.Join(t.TempDir(), "custom.json")

View File

@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
Brand: core.ParseBrand(selected.Brand),
}, nil
}
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
}, nil
}
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
}, nil
}
@@ -350,16 +350,6 @@ func sourceDisplayName(source string) string {
}
}
// normalizeBrand applies .strip().lower() and defaults to "feishu".
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
func normalizeBrand(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return "feishu"
}
return s
}
// resolveHermesEnvPath returns the path to Hermes's .env file.
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
//

View File

@@ -146,6 +146,20 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
// retryBrand decides whether app-registration polling must be retried on a
// different brand. When the secret was not issued and the tenant's real brand
// differs from the brand we polled, it returns the tenant's actual brand and true.
func retryBrand(polled core.LarkBrand, tenantBrand string) (core.LarkBrand, bool) {
if tenantBrand == "" {
return polled, false
}
actual := core.ParseBrand(tenantBrand)
if actual == polled {
return polled, false
}
return actual, true
}
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
@@ -208,18 +222,19 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
result, err := larkauth.PollAppRegistration(ctx, httpClient, larkBrand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
}
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
// Step 4: Retry on the tenant's actual brand if the secret was not issued on
// the polled brand (e.g. configured feishu but the tenant is a lark tenant).
if result.ClientSecret == "" && result.UserInfo != nil {
if rb, ok := retryBrand(larkBrand, result.UserInfo.TenantBrand); ok {
result, err = larkauth.PollAppRegistration(ctx, httpClient, rb, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "brand endpoint retry failed: %v", err).WithCause(err)
}
}
}
@@ -227,12 +242,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response
// Determine the final brand from the tenant's reported brand, normalized the
// same way as the retry decision (core.ParseBrand) so the saved brand always
// matches the endpoint the credentials were issued on.
finalBrand := larkBrand
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" {
finalBrand = core.ParseBrand(result.UserInfo.TenantBrand)
}
fmt.Fprintln(f.IOStreams.ErrOut)

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestRetryBrand(t *testing.T) {
cases := []struct {
polled core.LarkBrand
tenant string
wantBrand core.LarkBrand
wantRetry bool
}{
{core.BrandFeishu, "lark", core.BrandLark, true},
{core.BrandFeishu, "feishu", core.BrandFeishu, false},
{core.BrandFeishu, "", core.BrandFeishu, false},
{core.BrandLark, "feishu", core.BrandFeishu, true},
{core.BrandLark, "lark", core.BrandLark, false},
}
for _, c := range cases {
gotB, gotR := retryBrand(c.polled, c.tenant)
if gotR != c.wantRetry || gotB != c.wantBrand {
t.Errorf("retryBrand(%q,%q) = (%q,%v), want (%q,%v)", c.polled, c.tenant, gotB, gotR, c.wantBrand, c.wantRetry)
}
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
@@ -125,6 +126,10 @@ func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
updater.Brand = core.BrandFeishu
if cfg, err := opts.Factory.Config(); err == nil && cfg != nil {
updater.Brand = cfg.Brand
}
if !opts.Check {
updater.CleanupStaleFiles()

View File

@@ -9,6 +9,7 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -41,10 +42,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {

View File

@@ -16,6 +16,7 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
@@ -58,10 +59,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
acct := &credential.Account{
AppID: appID,

View File

@@ -39,6 +39,13 @@ type AppRegUserInfo struct {
TenantBrand string // "feishu" or "lark"
}
// appRegistrationEndpoint returns the accounts-domain registration endpoint for
// the given brand. Both the begin and poll actions target the same brand so the
// device_code issuer and poller domains always match.
func appRegistrationEndpoint(brand core.LarkBrand) string {
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
}
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
@@ -46,8 +53,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
}
ep := core.ResolveEndpoints(brand)
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
endpoint := appRegistrationEndpoint(brand)
form := url.Values{}
form.Set("action", "begin")
@@ -129,8 +135,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
const maxPollInterval = 60
const maxPollAttempts = 200
ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + PathAppRegistration
endpoint := appRegistrationEndpoint(brand)
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0

View File

@@ -6,6 +6,7 @@ package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
@@ -31,3 +32,18 @@ func Test_BuildVerificationURL(t *testing.T) {
})
})
}
func TestAppRegistrationEndpoint(t *testing.T) {
cases := []struct {
brand core.LarkBrand
want string
}{
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
}
for _, c := range cases {
if got := appRegistrationEndpoint(c.brand); got != c.want {
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
}
}
}

View File

@@ -3,9 +3,11 @@
package core
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// Any other string is treated as a custom base URL.
// Any unrecognized value is normalized to BrandFeishu (the default brand).
type LarkBrand string
const (
@@ -14,9 +16,10 @@ const (
)
// ParseBrand normalizes a brand string to a LarkBrand constant.
// Unrecognized values default to BrandFeishu.
// Matching is case-insensitive and whitespace-tolerant; any value other than
// "lark" (after trim + lowercase) normalizes to BrandFeishu.
func ParseBrand(value string) LarkBrand {
if value == "lark" {
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
return BrandLark
}
return BrandFeishu

View File

@@ -57,3 +57,23 @@ func TestResolveOpenBaseURL(t *testing.T) {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}
func TestParseBrand(t *testing.T) {
cases := []struct {
in string
want LarkBrand
}{
{"", BrandFeishu},
{"feishu", BrandFeishu},
{"lark", BrandLark},
{"LARK", BrandLark},
{" lark ", BrandLark},
{"Lark", BrandLark},
{"xyz", BrandFeishu},
}
for _, c := range cases {
if got := ParseBrand(c.in); got != c.want {
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
}
}
}

View File

@@ -75,13 +75,7 @@ func remoteMetaURL(version string) string {
if testMetaURL != "" {
return testMetaURL
}
var base string
switch configuredBrand {
case core.BrandLark:
base = "https://open.larksuite.com/api/tools/open/api_definition"
default:
base = "https://open.feishu.cn/api/tools/open/api_definition"
}
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
if version != "" {
q += "&data_version=" + url.QueryEscape(version)

View File

@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -49,7 +50,10 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
// officialSkillsIndexURL, when non-empty, overrides the brand-derived skills
// index URL. It is empty in production (the URL is resolved from the brand
// via core.ResolveEndpoints) and set by tests to a local server.
officialSkillsIndexURL = ""
)
// DetectResult holds installation detection results.
@@ -101,6 +105,10 @@ func (r *NpmResult) CombinedOutput() string {
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
// Brand selects the endpoint set for skills index/source resolution.
// Zero value resolves to the feishu default via core.ResolveEndpoints.
Brand core.LarkBrand
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
@@ -129,6 +137,21 @@ type Updater struct {
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// skillsIndexURL returns the well-known skills index URL for the Updater's
// brand. A non-empty officialSkillsIndexURL (tests only) takes precedence.
func (u *Updater) skillsIndexURL() string {
if officialSkillsIndexURL != "" {
return officialSkillsIndexURL
}
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
}
// skillsSource returns the skills repository source host for the Updater's
// brand, used as the `npx skills add <source>` argument.
func (u *Updater) skillsSource() string {
return core.ResolveEndpoints(u.Brand).Open
}
// DetectInstallMethod determines how the CLI was installed and whether the
// owning package manager is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
@@ -258,7 +281,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
if err != nil {
r.Err = err
return r
@@ -297,7 +320,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
r := u.runSkillsListOfficial(u.skillsSource())
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
@@ -313,7 +336,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
r := u.runSkillsInstall(u.skillsSource(), nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
@@ -321,7 +344,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd("https://open.feishu.cn")
r := u.runSkillsAdd(u.skillsSource())
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}

View File

@@ -17,6 +17,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
@@ -515,3 +516,23 @@ func TestDetectInstallMethod_Caches(t *testing.T) {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}
func TestSkillsBrandHosts(t *testing.T) {
cases := []struct {
brand core.LarkBrand
wantIndex string
wantSource string
}{
{core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"},
{core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"},
}
for _, c := range cases {
u := &Updater{Brand: c.brand}
if got := u.skillsIndexURL(); got != c.wantIndex {
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
}
if got := u.skillsSource(); got != c.wantSource {
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -23,7 +24,6 @@ import (
)
const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour
fetchTimeout = 15 * time.Second
stateFile = "update-state.json"
@@ -202,8 +202,25 @@ type npmLatestResponse struct {
Version string `json:"version"`
}
// resolveRegistryURL builds the npm registry metadata URL for @larksuite/cli.
// It honors npm_config_registry when set to a valid https registry (npm
// ecosystem convention), otherwise falls back to the public npmjs default.
// This request carries no Lark credentials — it is package-distribution only.
func resolveRegistryURL() string {
const pkgPath = "/@larksuite/cli/latest"
const defaultBase = "https://registry.npmjs.org"
raw := strings.TrimSpace(os.Getenv("npm_config_registry"))
if raw != "" {
if u, err := url.Parse(raw); err == nil && u.Scheme == "https" && u.Host != "" {
base := strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/")
return base + pkgPath
}
}
return defaultBase + pkgPath
}
func fetchLatestVersion() (string, error) {
resp, err := httpClient().Get(registryURL)
resp, err := httpClient().Get(resolveRegistryURL())
if err != nil {
return "", err
}

View File

@@ -275,3 +275,23 @@ func TestIsCIEnv(t *testing.T) {
})
}
}
func TestResolveRegistryURL(t *testing.T) {
cases := []struct {
name, env, want string
}{
{"unset", "", "https://registry.npmjs.org/@larksuite/cli/latest"},
{"custom https", "https://corp.example.com/repository/npm/", "https://corp.example.com/repository/npm/@larksuite/cli/latest"},
{"trailing slashes", "https://corp.example.com///", "https://corp.example.com/@larksuite/cli/latest"},
{"non-https ignored", "http://internal.example.com/", "https://registry.npmjs.org/@larksuite/cli/latest"},
{"garbage ignored", "not a url", "https://registry.npmjs.org/@larksuite/cli/latest"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv("npm_config_registry", c.env)
if got := resolveRegistryURL(); got != c.want {
t.Errorf("resolveRegistryURL() = %q, want %q", got, c.want)
}
})
}
}

View File

@@ -30,8 +30,21 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
└── scan_test.go
```
## Endpoint domain contract (`domaincontract`)
Every outbound Lark domain must be resolved through `core.ResolveEndpoints`.
The `domaincontract` domain rejects string literals containing a resolver-owned
host FQDN (`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`) in any
production `.go` file. The only allowlisted files are the resolver definition
(`internal/core/types.go`) and this rule's own host list
(`lint/domaincontract/scan.go`). Comments and `_test.go` files are not scanned.
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
## Running
```bash

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package domaincontract guards against hardcoded resolver-owned host FQDNs.
// Every outbound Lark domain must be resolved via core.ResolveEndpoints; the
// only place these host strings may appear as literals is the resolver itself.
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/lint/lintapi"
)
// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string
// literals in the allowlisted resolver source.
var forbiddenHosts = []string{
"open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn",
"open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com",
}
// allowlist maps repo-relative paths permitted to contain the literals: the
// resolver definition itself, and this rule's own forbidden-host list.
var allowlist = map[string]bool{
filepath.FromSlash("internal/core/types.go"): true,
filepath.FromSlash("lint/domaincontract/scan.go"): true,
}
func skipDir(name string) bool {
switch name {
case "vendor", "testdata", "node_modules", ".git", ".claude":
return true
}
return false
}
// ScanRepo walks production .go files under root and flags string literals
// containing a forbidden resolver host outside the allowlist. Comments and
// _test.go files are not scanned.
func ScanRepo(root string) ([]lintapi.Violation, error) {
var out []lintapi.Violation
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if skipDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr == nil && allowlist[rel] {
return nil
}
fset := token.NewFileSet()
file, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return nil // unparseable file: not our concern
}
display := path
if relErr == nil {
display = rel
}
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
for _, host := range forbiddenHosts {
if strings.Contains(lit.Value, host) {
pos := fset.Position(lit.Pos())
out = append(out, lintapi.Violation{
Rule: "no-hardcoded-endpoint",
Action: lintapi.ActionReject,
File: display,
Line: pos.Line,
Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints",
Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host",
})
return true
}
}
return true
})
return nil
})
return out, err
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, root, rel, content string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestScanRepo(t *testing.T) {
root := t.TempDir()
// Negative: allowlisted resolver source may hold the literals.
writeFile(t, root, "internal/core/types.go", "package core\n\nvar x = \"https://open.feishu.cn\"\n")
// Negative: non-resolver hosts + a comment reference must not trip the guard.
writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n")
// Negative: _test.go files may assert literals.
writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n")
// Positive: production literal outside the allowlist.
writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n")
vs, err := ScanRepo(root)
if err != nil {
t.Fatal(err)
}
if len(vs) != 1 {
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
}
if filepath.Base(vs[0].File) != "z.go" {
t.Errorf("violation in %q, want z.go", vs[0].File)
}
}

View File

@@ -30,6 +30,7 @@ import (
"fmt"
"os"
"github.com/larksuite/cli/lint/domaincontract"
"github.com/larksuite/cli/lint/errscontract"
"github.com/larksuite/cli/lint/lintapi"
)
@@ -43,6 +44,9 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
}},
}
func main() {

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
@@ -247,10 +246,7 @@ var EventSubscribe = common.Shortcut{
}
// --- WebSocket ---
domain := lark.FeishuBaseUrl
if runtime.Config.Brand == core.BrandLark {
domain = lark.LarkBaseUrl
}
domain := core.ResolveEndpoints(runtime.Config.Brand).Open
info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset))
if eventTypeFilter != nil {

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"testing"
"github.com/larksuite/cli/internal/core"
lark "github.com/larksuite/oapi-sdk-go/v3"
)
// TestWSDomainMatchesResolver guards the invariant that lets the event
// WebSocket domain be resolved via core.ResolveEndpoints instead of the SDK
// base-URL constants: the resolver's Open host must equal the SDK's per-brand
// base URL. If the SDK constants ever drift from the resolver, this fails.
func TestWSDomainMatchesResolver(t *testing.T) {
if got, want := core.ResolveEndpoints(core.BrandFeishu).Open, lark.FeishuBaseUrl; got != want {
t.Errorf("feishu WS domain = %q, want SDK %q", got, want)
}
if got, want := core.ResolveEndpoints(core.BrandLark).Open, lark.LarkBaseUrl; got != want {
t.Errorf("lark WS domain = %q, want SDK %q", got, want)
}
}

View File

@@ -46,7 +46,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md),未给 URL/token 时先用 `lark-cli drive +search --doc-types mindnote` 定位 Mindnote 文档 token;新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理

View File

@@ -9,32 +9,6 @@
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 获取 `mindnote_id`
`--mindnote-id`**Mindnote 文档 token**,不是节点 ID。`lark-cli mindnotes` 只负责读取和写入思维笔记内部节点,不提供按标题列出 Mindnote 文档的入口;找文档要先走 Drive。
```bash
# 用户给了 Mindnote URL或给了可能包着 Mindnote 的 Wiki URL
lark-cli drive +inspect --url "<mindnote_or_wiki_url>"
# 用户只给了标题、关键词,或需要在云空间里找思维笔记
lark-cli drive +search --query "<关键词>" --doc-types mindnote --format table
# 用户想浏览自己负责的思维笔记
lark-cli drive +search --doc-types mindnote --mine --sort edit_time --format table
```
处理规则:
- 普通 Mindnote URL`drive +inspect` 返回的 Mindnote token 可作为 `--mindnote-id`
- Wiki URL不要把 `/wiki/` 路径里的 wiki token 当作 `--mindnote-id`;必须先 `drive +inspect` 解包,确认底层类型是 `mindnote` 后再使用返回的真实 token。直接把 wiki token 传给 `mindnotes nodes list` 通常会返回 `3410003 resource not found`
- 标题 / 关键词:用 `drive +search --doc-types mindnote` 定位;多候选时先让用户确认目标,不要猜。
## 所需权限
- 读取节点:需要用户授权包含 `mindnote:node:read`
- 创建 / 更新节点:需要用户授权包含对应写入 scope如果命令返回 `missing_scope`,按 [`lark-shared`](../../lark-shared/SKILL.md) 的授权恢复流程,用错误里的 `hint``missing_scopes` 重新执行 `lark-cli auth login --scope ...`
## 命令
```bash
@@ -122,8 +96,8 @@ lark-cli mindnotes nodes create \
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先按上方「获取 `mindnote_id`」定位 Mindnote 文档 token。
4. 确认目标类型**Mindnote**,把真实 Mindnote token 作为 `--mindnote-id`
3. 如果是操作已有思维笔记,先通过 token 类别判断
4. 确认是 **Mindnote**再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
@@ -136,5 +110,4 @@ lark-cli mindnotes nodes create \
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-drive](../../lark-drive/SKILL.md) — 搜索和解析 Mindnote / Wiki 等云空间资源
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -24,7 +24,7 @@ metadata:
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。

View File

@@ -1,6 +1,12 @@
# 知识整理工作流
This file is the single entry point for the knowledge organization workflow. It defines the global contract, state machine, and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
Workflow id: `knowledge_organize`
Risk / Structure: `R2-R3` / `S3`
This file implements the registered knowledge organization workflow. Before execution, the agent MUST read [`lark-drive-workflow.md`](lark-drive-workflow.md) and [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md), and follow the shared execution protocol, Artifact Contract, Workflow Loading rules, authentication rules, and write confirmation rules.
It defines the workflow-specific state machine and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
Phase files are references for this workflow, not independent skills. Do not route user requests directly to a phase file.
@@ -80,7 +86,7 @@ When this workflow is triggered, the agent MUST:
## Runtime State
Agent MUST maintain these internal fields during one workflow run:
This workflow extends the shared Artifact Contract. Agent MUST maintain these internal fields during one workflow run:
| Field | Meaning |
|-------|---------|
@@ -114,24 +120,24 @@ Agent MUST maintain these internal fields during one workflow run:
## Execution State Machine
| State | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_SCOPE` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
| `INVENTORY` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
| `CONTENT_READ` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
| `ISSUE_ANALYSIS` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
| `RULE_GENERATION` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
| `PLAN_GENERATION` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
| `EXEC_CONFIRM` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
| `VERIFY` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
| `ROLLBACK_CONFIRM` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
| `ROLLBACK` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
| `ROLLBACK_VERIFY` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
| `ROLLBACK_CLEANUP_CONFIRM` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
| `ROLLBACK_CLEANUP` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
| `ROLLBACK_CLEANUP_VERIFY` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
| `DONE` | No more action | Stop | Final answer | `false` | End |
| State | Protocol Step | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_SCOPE` | `route` / `scope` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
| `INVENTORY` | `read` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
| `CONTENT_READ` | `read` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
| `ISSUE_ANALYSIS` | `assess` / `plan` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
| `RULE_GENERATION` | `assess` / `plan` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
| `PLAN_GENERATION` | `assess` / `plan` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
| `EXEC_CONFIRM` | `confirm` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | `execute` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
| `VERIFY` | `verify` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
| `ROLLBACK_CONFIRM` | `recovery confirm` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
| `ROLLBACK` | `recovery execute` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
| `ROLLBACK_VERIFY` | `recovery verify` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
| `ROLLBACK_CLEANUP_CONFIRM` | `cleanup confirm` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
| `ROLLBACK_CLEANUP` | `cleanup execute` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
| `ROLLBACK_CLEANUP_VERIFY` | `cleanup verify` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
| `DONE` | `done` | No more action | Stop | Final answer | `false` | End |
## Progressive Load Map

View File

@@ -97,7 +97,7 @@ Structure Level
2. Entry file 超过约 300 行时,优先拆 `commands``outputs``artifacts` reference。
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template不默认新增独立 workflow。
5. 已有样板:`permission_governance``R2/S2`已发布的独立 `knowledge_organize``R2-R3/S3`,当前不作为本总框架 registry entry
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``R2-R3/S3`
## 加载与拆分边界
@@ -111,6 +111,7 @@ Structure Level
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
## Workflow Loading

View File

@@ -7,7 +7,7 @@
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
- `icon`: small semantic symbol for a concept, step, role, or status.
- `logo`: brand, product, team, or customer mark.
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
- `screenshot`: product UI, terminal output, workflow state, or page capture.
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
@@ -64,11 +64,23 @@ Match asset type to slide role:
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
For `asset_type: "chart"`:
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
- Choose the data source explicitly:
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
`fallback_if_missing` must be concrete enough to turn into XML, for example:
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
- "Render a mini bar chart with 4 bars using shapes and value labels."
- "Render a native `<chart>` using the user-provided series."
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
- "Use a bordered placeholder panel with product area labels, not an empty image."
Weak fallbacks to avoid:
@@ -118,7 +130,7 @@ Business comparison page:
When generating XML:
1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.

View File

@@ -2,6 +2,8 @@
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG****Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>``<shape>` 难以覆盖的视觉内容。
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
---
@@ -12,13 +14,13 @@
| 场景 | 推荐元素 |
|------|---------|
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑
---
@@ -39,9 +41,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
## SVG 还是 Mermaid
选择分步:**先看图表类型,看当前模型身份**。
选择分步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
### 第一步:图表类型优先判断
### 第一步:先确认是否应该使用 `<chart>`
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
### 第二步whiteboard 类型优先判断
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG
@@ -50,20 +56,19 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
| 时序图 | `sequenceDiagram` |
| 类图 | `classDiagram` |
| 饼图 | `pie` |
| 甘特图 | `gantt` |
| 状态图 | `stateDiagram-v2` |
| 思维导图 | `mindmap` |
| ER 图 | `erDiagram` |
### 第步:数据图表与装饰元素按模型身份选路径
### 第步:非原生图表与装饰元素按模型身份选路径
上表以外的场景散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等需要精确控制坐标和配色SVG 表达力更强,但各模型生成 SVG 的能力有差异:
| 模型身份 | 路径 |
|----------|------|
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
| Doubao / Seed / Other | **Mermaid** — 用 `pie``gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
| Doubao / Seed / Other | **Mermaid** — 用 `gantt``flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
@@ -73,13 +78,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
### ⚠️ 设计品质要求
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或
- **非原生数据视觉必须有坐标系**散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
- **装饰层次用亮度跳跃,不用线性叠透明度**`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
@@ -106,11 +111,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
| 元素 | 说明 | 典型用途 |
|------|------|---------|
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
| `<circle>` | 圆 | 节点、装饰点、环形图 |
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
| `<line>` | 直线 | 坐标轴、分隔线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<line>` | 直线 | 轴线、分隔线、连接线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<text>` | 文本,支持中文 | 标签、数值 |
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
| `<g>` | 分组 | 批量变换、语义分组 |
@@ -123,27 +128,25 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
---
### 元素计算
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
**数据图表(柱状图范式**
**散点图 / 装饰性点阵范式**
```python
W, H = 360, 260
origin_x, origin_y = 50, 216 # 左下角SVG Y 轴向下
cw, ch = 290, 184
data, y_max = [120, 160, 90], 200
bar_w = int(cw / len(data) * 0.62)
for i, v in enumerate(data):
cx = round(origin_x + (i + 0.5) * cw / len(data))
y = round(origin_y - v / y_max * ch)
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
points = [(12, 40), (28, 80), (45, 65)]
x_min, x_max, y_min, y_max = 0, 50, 0, 100
for i, (xv, yv) in enumerate(points):
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
print(f"point-{i}: cx={x} cy={y}")
```
折线图:`x = origin_x + i/(n-1)*cw``y = origin_y - (v-y_min)/(y_max-y_min)*ch`
**装饰性元素(等间距范式)**
```python
@@ -160,9 +163,9 @@ for i in range(n):
```python
# 每个元素登记 (x, y, w, h),含 stroke 外扩
elements = [
(10, 20, 80, 160), # bar-0
(107, 10, 80, 170), # bar-1
(204, 40, 80, 140), # bar-2
(10, 20, 80, 160), # item-0
(107, 10, 80, 170), # item-1
(204, 40, 80, 140), # item-2
(0, 0, 300, 1), # x-axis
]
@@ -261,7 +264,6 @@ print(f"whiteboard width={wb_w} height={wb_h}")
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
| 甘特图 | `gantt` | 项目计划、里程碑 |
| 饼图 | `pie` | 占比数据 |
| 类图 | `classDiagram` | 对象关系、架构设计 |
| ER 图 | `erDiagram` | 数据库结构 |
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
@@ -279,7 +281,6 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|---------|-----------|------------|
| 流程图5-8 节点) | 720-816 | 300-400 |
| 时序图3-5 参与者) | 720-816 | 320-420 |
| 饼图 | 500-600 | 300-360 |
| 甘特图 | 816 | 280-360 |
| 思维导图 | 816 | 380-480 |
@@ -307,7 +308,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size避免被裁切
**SVG 模式——视觉品质检查:**
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
- [ ] 轴标签与图表元素互不遮挡,留有足够空间

View File

@@ -119,6 +119,34 @@ Each slide must include:
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
Optional slide fields:
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
```json
{
"chart_contract": {
"required": true,
"render_as": "native_chart",
"chart_type": "line",
"data_source": "mock_placeholder",
"data_series_required": true,
"placeholder_label_required": true,
"manual_shape_fallback_allowed": false
}
}
```
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
`data_source` must be one of:
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
@@ -183,6 +211,7 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
- `purpose`: why this asset helps the page's key message.
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
For detailed rules and examples, read `asset-planning.md`.
@@ -190,7 +219,7 @@ Good examples:
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
## XML Generation Contract
@@ -201,6 +230,7 @@ Before writing each slide XML, map the plan fields to concrete decisions:
- `visual_focus` determines the largest visual region or emphasized object.
- `text_density` caps visible text volume.
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
After creating the PPT, fetch the presentation and verify:

File diff suppressed because one or more lines are too long

View File

@@ -3018,7 +3018,7 @@
子元素(mermaid 与 svg 二选一):
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
示例: &lt;mermaid&gt;&lt;![CDATA[flowchart TD\n A[开始] --&gt; B[结束]]]&gt;&lt;/mermaid&gt;
- svg: SVG 内容

View File

@@ -263,7 +263,56 @@
- `<chartLegend>`
- `<chartTooltip>`
如果要写图表 XML建议直接以 XSD 为准,不要自行发明更简化的 chart DSL
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例
组合图示例(来自 [slides_chart_demo.xml](slides_chart_demo.xml)
```xml
<chart width="556" height="350" topLeftX="42" topLeftY="132">
<chartPlotArea>
<chartPlot type="combo">
<chartExtra/>
<chartSeriesList>
<chartSeries index="1" comboType="column"/>
<chartSeries index="2" comboType="line" yAxisPosition="right">
<chartTooltip format="0%"/>
</chartSeries>
</chartSeriesList>
</chartPlot>
<chartAxes>
<chartAxis type="x">
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="left">
<chartGridLine color="rgb(226, 232, 240)"/>
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="right">
<chartLabel fontSize="10" format="0%"/>
</chartAxis>
</chartAxes>
</chartPlotArea>
<chartLegend position="bottom" fontSize="11"/>
<chartData>
<dim1>
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
</dim1>
<dim2>
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
</dim2>
</chartData>
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
<chartStyle>
<chartBackground color="rgba(0, 0, 0, 0)"/>
<chartBorder color="rgb(222, 224, 227)" width="0"/>
<chartColorTheme>
<color value="rgb(28, 71, 120)"/>
<color value="rgb(240, 129, 54)"/>
</chartColorTheme>
</chartStyle>
</chart>
```
## 样式元素

View File

@@ -147,7 +147,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
### whiteboard
```xml
<!-- SVG 模式:数据图表、装饰元素 -->
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
<svg xmlns="http://www.w3.org/2000/svg">
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>

View File

@@ -24,7 +24,7 @@ metadata:
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md)该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`