mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Compare commits
4 Commits
v1.0.29
...
chore/spli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7827fcde | ||
|
|
ddc24fec90 | ||
|
|
25454f498b | ||
|
|
62ff3d66a6 |
@@ -6,14 +6,14 @@
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 25 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
## Why lark-cli?
|
||||
|
||||
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
|
||||
- **Agent-Native Design** — 25 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
|
||||
- **Wide Coverage** — 17 business domains, 200+ curated commands, 25 AI Agent [Skills](./skills/)
|
||||
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
|
||||
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
|
||||
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
|
||||
@@ -142,7 +142,8 @@ lark-cli auth status
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-slides-creator` | Create polished presentations with planning, design, asset, template, and validation workflows |
|
||||
| `lark-slides` | Low-level Slides XML/API read/write operations |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
|
||||
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
|
||||
|
||||
@@ -232,7 +232,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
|
||||
@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout ≥ 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询,**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code,导致用户授权的链接失效。",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询。**不要短 timeout 反复重试**,每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
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 long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** — each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
|
||||
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 long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
|
||||
@@ -879,6 +879,57 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginRun() error = %v", err)
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||
var data map[string]interface{}
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
|
||||
}
|
||||
hint, _ := data["hint"].(string)
|
||||
for _, want := range []string{
|
||||
"exactly as returned by the CLI",
|
||||
"opaque string",
|
||||
"Do not URL-encode or decode it",
|
||||
"do not add %20, spaces, or punctuation",
|
||||
"do not wrap it as Markdown link text",
|
||||
"fenced code block containing only the raw URL",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
@@ -917,6 +968,60 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(stdout.String()))
|
||||
var data map[string]interface{}
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
|
||||
}
|
||||
hint, _ := data["agent_hint"].(string)
|
||||
for _, want := range []string{
|
||||
"timeout >= 600s",
|
||||
"逐字原样转发 CLI 返回的 URL",
|
||||
"opaque string",
|
||||
"不要做 URL 编码或解码",
|
||||
"不要补 `%20`、空格或标点",
|
||||
"不要改写成 Markdown 链接",
|
||||
"只包含该 URL 的代码块单独输出",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
|
||||
@@ -168,6 +168,11 @@ func TestUpdateManual_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -195,6 +200,9 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_Human(t *testing.T) {
|
||||
// Same isolation as TestUpdateNpm_JSON — see comment there.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
@@ -222,6 +230,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--force", "--json"})
|
||||
@@ -312,6 +323,9 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -629,6 +643,9 @@ func TestPermissionHint(t *testing.T) {
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
|
||||
@@ -65,7 +65,11 @@ func AssertSecurePath(params AuditParams) (string, error) {
|
||||
}
|
||||
|
||||
// requireAbsolutePath rejects relative paths; relative paths would depend on
|
||||
// the process cwd and defeat the point of a static audit.
|
||||
// the process cwd and defeat the point of a static audit. Shell-style
|
||||
// shortcuts like `~` are home-relative, not cwd-relative — they are an
|
||||
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
|
||||
// Callers that accept user-authored config (e.g. resolveFileRef) must
|
||||
// pre-resolve any such shortcuts before passing the path in.
|
||||
func requireAbsolutePath(target, label string) error {
|
||||
if !filepath.IsAbs(target) {
|
||||
return fmt.Errorf("%s: path must be absolute, got %q", label, target)
|
||||
|
||||
@@ -23,9 +23,19 @@ func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
|
||||
return "", fmt.Errorf("file provider path is empty")
|
||||
}
|
||||
|
||||
// OpenClaw preserves user-authored `~/...` paths verbatim on disk for
|
||||
// portability and resolves them at read time. lark-cli reads the file
|
||||
// raw, so we mirror that resolution here before the audit — otherwise
|
||||
// an unambiguous home-relative path would be rejected by
|
||||
// requireAbsolutePath, which is meant to guard against cwd-relative
|
||||
// paths (a different concern). expandTildePath honours OPENCLAW_HOME so
|
||||
// a tilde inside an OPENCLAW_HOME-overridden config resolves to the
|
||||
// same absolute path OpenClaw itself would have used.
|
||||
targetPath := expandTildePath(pc.Path)
|
||||
|
||||
// Security audit on file path
|
||||
securePath, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: pc.Path,
|
||||
TargetPath: targetPath,
|
||||
Label: "secrets.providers file path",
|
||||
TrustedDirs: pc.TrustedDirs,
|
||||
AllowInsecurePath: pc.AllowInsecurePath,
|
||||
|
||||
@@ -6,6 +6,7 @@ package binding
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -230,3 +231,88 @@ func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
|
||||
t.Errorf("error = %q, want %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFileRef_TildePath_SingleValue is the end-to-end smoke test
|
||||
// for the fix: a singleValue file provider with a ~/-relative path
|
||||
// resolves correctly through resolveFileRef. Before this PR the audit
|
||||
// would reject the path as "must be absolute".
|
||||
func TestResolveFileRef_TildePath_SingleValue(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
setFakeOSHome(t, dir)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(p, []byte("tilde_secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: "~/secret.txt",
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "tilde_secret" {
|
||||
t.Errorf("got %q, want %q", got, "tilde_secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFileRef_RelativePath_StillRejected guards the absolute-path
|
||||
// audit: cwd-relative input must still be rejected even though tilde was
|
||||
// loosened. Catches regressions if expandTildePath is ever widened to
|
||||
// also expand "./..." (which would weaken the audit's invariant).
|
||||
func TestResolveFileRef_RelativePath_StillRejected(t *testing.T) {
|
||||
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: "relative/secret.txt",
|
||||
Mode: "singleValue",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
_, err := resolveFileRef(ref, pc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for relative path, got nil")
|
||||
}
|
||||
wantSub := "path must be absolute"
|
||||
if !strings.Contains(err.Error(), wantSub) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), wantSub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFileRef_TildePath_JSONMode verifies the tilde-expansion
|
||||
// path works for json mode (where ref id is a JSON pointer) as well as
|
||||
// singleValue mode — the mechanism is mode-agnostic.
|
||||
func TestResolveFileRef_TildePath_JSONMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
setFakeOSHome(t, dir)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
p := filepath.Join(dir, "secrets.json")
|
||||
content := `{"providers":{"feishu":{"key":"json_via_tilde"}}}`
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
|
||||
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
|
||||
pc := &ProviderConfig{
|
||||
Source: "file",
|
||||
Path: "~/secrets.json",
|
||||
Mode: "json",
|
||||
AllowInsecurePath: true,
|
||||
}
|
||||
|
||||
got, err := resolveFileRef(ref, pc)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "json_via_tilde" {
|
||||
t.Errorf("got %q, want %q", got, "json_via_tilde")
|
||||
}
|
||||
}
|
||||
|
||||
180
internal/binding/tilde.go
Normal file
180
internal/binding/tilde.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// hasTildePrefix reports whether s begins with `~` followed by end-of-string,
|
||||
// `/`, or `\` — the form OpenClaw treats as home-relative.
|
||||
func hasTildePrefix(s string) bool {
|
||||
if s == "" || s[0] != '~' {
|
||||
return false
|
||||
}
|
||||
if len(s) == 1 {
|
||||
return true
|
||||
}
|
||||
return s[1] == '/' || s[1] == '\\'
|
||||
}
|
||||
|
||||
// joinTildeSuffix expands a tilde-prefixed string against a resolved home
|
||||
// directory. Replaces only the leading `~` so the original separator
|
||||
// (forward or back slash) and suffix bytes are kept verbatim, matching
|
||||
// OpenClaw's `input.replace(/^~(?=$|[\\/])/, home)` semantics rather than
|
||||
// going through filepath.Join (which would silently drop a literal `\` on
|
||||
// POSIX). filepath.Clean is applied so `..` and duplicate separators are
|
||||
// collapsed in the same way Node's path.resolve does on each platform.
|
||||
//
|
||||
// Caller must ensure hasTildePrefix(s) is true and home is non-empty.
|
||||
func joinTildeSuffix(s, home string) string {
|
||||
if len(s) == 1 {
|
||||
return home
|
||||
}
|
||||
return filepath.Clean(home + s[1:])
|
||||
}
|
||||
|
||||
// normalizeSentinel applies OpenClaw's normalize() helper to a single
|
||||
// string: trims whitespace and treats the JS-flavoured literals
|
||||
// "undefined" / "null" (along with empty/whitespace-only) as unset.
|
||||
func normalizeSentinel(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "undefined" || v == "null" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// osHome returns the OS-level home directory by walking OpenClaw's
|
||||
// resolution chain: HOME → USERPROFILE → OS user database (getpwuid on
|
||||
// Unix / user32 on Windows, via os/user.Current). Each candidate is
|
||||
// passed through normalizeSentinel so sentinel literals and blank
|
||||
// strings fall through.
|
||||
//
|
||||
// Matches OpenClaw's resolveRawOsHomeDir env chain so the same tilde
|
||||
// resolves against the same home under mixed shell environments and
|
||||
// accidentally-stringified env values. Go's stdlib os.UserHomeDir on
|
||||
// Unix only re-reads HOME and gives up; Node's os.homedir() still
|
||||
// returns the account home via the user database, so the explicit
|
||||
// user.Current() step is what keeps OpenClaw-authored `~/...` working
|
||||
// in HOME-unset shells.
|
||||
//
|
||||
// Deliberate hybrid contract — neither a strict mirror of OpenClaw
|
||||
// nor a strict reject-on-missing:
|
||||
//
|
||||
// - OpenClaw's final fallback is cwd (via resolveRequiredHomeDir →
|
||||
// process.cwd()). We don't do that because requireAbsolutePath
|
||||
// exists precisely to reject cwd-dependent paths; routing
|
||||
// `~/secret` through cwd would defeat the audit invariant.
|
||||
//
|
||||
// - We still go through user.Current() before giving up, even when
|
||||
// HOME is a sentinel literal ("undefined" / "null") and
|
||||
// USERPROFILE is unset. At that point OpenClaw would land on cwd,
|
||||
// and a strict implementation would reject; user.Current() lands
|
||||
// on the account home instead — cwd-independent and user-bound,
|
||||
// so it satisfies the audit's safety goal while still letting
|
||||
// ~/-authored configs resolve in a malformed-env shell.
|
||||
//
|
||||
// - Only returns "" when the env chain AND user.Current() are all
|
||||
// unresolvable, at which point the caller surfaces a clean
|
||||
// "path must be absolute" error from the audit.
|
||||
func osHome() string {
|
||||
if v := normalizeSentinel(os.Getenv("HOME")); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := normalizeSentinel(os.Getenv("USERPROFILE")); v != "" {
|
||||
return v
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
return normalizeSentinel(u.HomeDir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// explicitOpenClawHome reads OPENCLAW_HOME with OpenClaw's normalize()
|
||||
// semantics applied.
|
||||
func explicitOpenClawHome() string {
|
||||
return normalizeSentinel(os.Getenv("OPENCLAW_HOME"))
|
||||
}
|
||||
|
||||
// absolutize returns p as an absolute path, resolving against the process
|
||||
// cwd when p is relative. Returns "" when the cwd cannot be resolved.
|
||||
// Wraps filepath.Abs semantics via vfs.Getwd because forbidigo bans
|
||||
// filepath.Abs inside internal/ packages.
|
||||
func absolutize(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(p) {
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
wd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(wd, p)
|
||||
}
|
||||
|
||||
// openClawHome returns the home directory used to resolve `~`-relative paths
|
||||
// authored against OpenClaw's config. Closely mirrors OpenClaw's
|
||||
// home-resolution semantics so the same tilde resolves to the same
|
||||
// absolute path here as inside OpenClaw runtime under all normal
|
||||
// conditions.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. OPENCLAW_HOME env var, when set (sentinel-normalised).
|
||||
// 2. If OPENCLAW_HOME itself has a tilde prefix, expand it against the OS
|
||||
// home (see osHome); the result is empty when the OS home is
|
||||
// unresolvable.
|
||||
// 3. Otherwise fall back to the OS home.
|
||||
//
|
||||
// The returned path is absolute (relative OPENCLAW_HOME values are
|
||||
// absolutised against the process cwd, matching Node path.resolve in
|
||||
// OpenClaw's pipeline).
|
||||
//
|
||||
// Returns "" when no home can be resolved. This is a deliberate
|
||||
// divergence from OpenClaw, whose read pipeline would fall back to
|
||||
// cwd via resolveRequiredHomeDir — see osHome for the rationale.
|
||||
func openClawHome() string {
|
||||
raw := explicitOpenClawHome()
|
||||
switch {
|
||||
case raw == "":
|
||||
raw = osHome()
|
||||
case hasTildePrefix(raw):
|
||||
h := osHome()
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
raw = joinTildeSuffix(raw, h)
|
||||
}
|
||||
return absolutize(raw)
|
||||
}
|
||||
|
||||
// expandTildePath resolves a leading `~` or `~/...` prefix to OpenClaw's
|
||||
// effective home directory (see openClawHome).
|
||||
//
|
||||
// Returns the input unchanged when it lacks a tilde prefix or when
|
||||
// openClawHome cannot resolve a home directory. The latter case is a
|
||||
// deliberate divergence from OpenClaw, whose read pipeline falls back
|
||||
// to cwd — see osHome. Surfacing a "path must be absolute" error from
|
||||
// the audit is preferable to silently routing a user-authored
|
||||
// `~/secret` through cwd resolution.
|
||||
//
|
||||
// `~user` shell-style expansion is intentionally not supported (OpenClaw
|
||||
// does not support it either).
|
||||
func expandTildePath(p string) string {
|
||||
if !hasTildePrefix(p) {
|
||||
return p
|
||||
}
|
||||
home := openClawHome()
|
||||
if home == "" {
|
||||
return p
|
||||
}
|
||||
return joinTildeSuffix(p, home)
|
||||
}
|
||||
293
internal/binding/tilde_test.go
Normal file
293
internal/binding/tilde_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setFakeOSHome controls osHome's env-chain inputs (HOME and USERPROFILE)
|
||||
// in one call so tests stay deterministic across platforms. osHome reads
|
||||
// HOME first, then USERPROFILE, then user.Current(); setting only one of
|
||||
// the two leaves the test sensitive to whichever the runner happens to
|
||||
// have populated. Passing dir == "" disables both env entries so tests
|
||||
// can exercise the user.Current() fallback or no-home edge cases.
|
||||
func setFakeOSHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
}
|
||||
|
||||
// isolateRuntimeWrites parks the process cwd in a fresh TempDir for the
|
||||
// test's duration. Tests that set HOME to a sentinel literal trigger Go
|
||||
// runtime side effects — most visibly the telemetry subsystem, which
|
||||
// calls os.UserConfigDir() (= "$HOME/Library/Application Support" on
|
||||
// darwin) and happily writes through a relative result like
|
||||
// "undefined/Library/...". Without isolation those files land in the
|
||||
// package or repo dir and get accidentally staged. Chdir'ing into a
|
||||
// TempDir routes the noise into a path testing.T auto-cleans.
|
||||
func isolateRuntimeWrites(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(orig)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenClawHome covers the openClawHome resolution table: empty /
|
||||
// sentinel OPENCLAW_HOME falls back to the OS home, explicit absolute
|
||||
// values are used verbatim (with whitespace trimmed), and tilde-prefixed
|
||||
// values recurse through the OS home.
|
||||
func TestOpenClawHome(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
explicit := t.TempDir()
|
||||
setFakeOSHome(t, homeDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
openclawEnv string
|
||||
want string
|
||||
}{
|
||||
{"unset falls back to OS home", "", homeDir},
|
||||
{"undefined literal treated as unset", "undefined", homeDir},
|
||||
{"null literal treated as unset", "null", homeDir},
|
||||
{"whitespace-only treated as unset", " ", homeDir},
|
||||
{"explicit absolute path used verbatim", explicit, explicit},
|
||||
{"explicit absolute path is trimmed", " " + explicit + " ", explicit},
|
||||
{"bare tilde resolves to OS home", "~", homeDir},
|
||||
{"tilde-prefixed value recurses through OS home", "~/custom", filepath.Join(homeDir, "custom")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", tc.openclawEnv)
|
||||
got := openClawHome()
|
||||
if got != tc.want {
|
||||
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_RelativeIsAbsolutized confirms a relative
|
||||
// OPENCLAW_HOME is resolved against the process cwd, mirroring Node's
|
||||
// path.resolve behaviour in OpenClaw.
|
||||
func TestOpenClawHome_RelativeIsAbsolutized(t *testing.T) {
|
||||
t.Setenv("OPENCLAW_HOME", filepath.FromSlash("relative/dir"))
|
||||
got := openClawHome()
|
||||
|
||||
if !filepath.IsAbs(got) {
|
||||
t.Fatalf("openClawHome() = %q, want absolute path", got)
|
||||
}
|
||||
wantSuffix := filepath.FromSlash("relative/dir")
|
||||
if !strings.HasSuffix(got, wantSuffix) {
|
||||
t.Errorf("openClawHome() = %q, want suffix %q", got, wantSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_FallsBackToUserDatabase pins osHome's final fallback
|
||||
// to the OS user database when HOME and USERPROFILE are both unset,
|
||||
// matching Node's os.homedir() (which uses getpwuid). Cwd-independent
|
||||
// and user-bound, so it does not conflict with the "no cwd fallback"
|
||||
// rule documented on osHome.
|
||||
func TestOpenClawHome_FallsBackToUserDatabase(t *testing.T) {
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
setFakeOSHome(t, "")
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
got := openClawHome()
|
||||
if got != u.HomeDir {
|
||||
t.Errorf("openClawHome() = %q, want %q (account home from user.Current)", got, u.HomeDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback pins that
|
||||
// a tilde-form OPENCLAW_HOME ("~/custom") expands against the
|
||||
// user-database fallback when HOME and USERPROFILE are both unset.
|
||||
// Without the user.Current() step in osHome this would have failed
|
||||
// (returning "") and dropped the bind back to the audit's
|
||||
// "path must be absolute" error.
|
||||
func TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback(t *testing.T) {
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
setFakeOSHome(t, "")
|
||||
t.Setenv("OPENCLAW_HOME", "~/custom")
|
||||
got := openClawHome()
|
||||
want := filepath.Join(u.HomeDir, "custom")
|
||||
if got != want {
|
||||
t.Errorf("openClawHome() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath covers the full input grid for expandTildePath:
|
||||
// bare tilde, tilde-slash, tilde + suffix, nested suffix, plain absolute
|
||||
// and relative literals, and the intentionally-unchanged forms (~user,
|
||||
// ~foo) that OpenClaw does not expand either.
|
||||
func TestExpandTildePath(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
absFixture := filepath.Join(fakeHome, "abs.json")
|
||||
setFakeOSHome(t, fakeHome)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"bare tilde", "~", fakeHome},
|
||||
{"tilde slash", "~/", fakeHome},
|
||||
{"tilde with file", "~/secret.json", filepath.Join(fakeHome, "secret.json")},
|
||||
{"tilde with nested path", "~/.openclaw/secret.json", filepath.Join(fakeHome, ".openclaw/secret.json")},
|
||||
{"absolute unchanged", absFixture, absFixture},
|
||||
{"relative unchanged", "foo/bar", "foo/bar"},
|
||||
{"dot relative unchanged", "../foo", "../foo"},
|
||||
{"tilde user form unchanged", "~root/foo", "~root/foo"},
|
||||
{"tilde without separator unchanged", "~foo", "~foo"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := expandTildePath(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("expandTildePath(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath_RespectsOpenClawHome verifies that with
|
||||
// OPENCLAW_HOME set, tilde expansion uses that custom home rather than
|
||||
// the OS home — the integration-level invariant that closes the
|
||||
// internal inconsistency CodeX's first review flagged.
|
||||
func TestExpandTildePath_RespectsOpenClawHome(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
clawHome := t.TempDir()
|
||||
setFakeOSHome(t, homeDir)
|
||||
t.Setenv("OPENCLAW_HOME", clawHome)
|
||||
|
||||
got := expandTildePath("~/secret.json")
|
||||
want := filepath.Join(clawHome, "secret.json")
|
||||
if got != want {
|
||||
t.Errorf("expandTildePath(%q) = %q, want %q (should use OPENCLAW_HOME)", "~/secret.json", got, want)
|
||||
}
|
||||
if got == filepath.Join(homeDir, "secret.json") {
|
||||
t.Errorf("expandTildePath unexpectedly used OS home %q instead of OPENCLAW_HOME %q", homeDir, clawHome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath_FallsBackToUserDatabase is the end-to-end
|
||||
// equivalent of TestOpenClawHome_FallsBackToUserDatabase: with HOME and
|
||||
// USERPROFILE both unset, expandTildePath still resolves `~/foo` via
|
||||
// osHome's user.Current() step. Matches Node os.homedir() and keeps
|
||||
// OpenClaw-authored configs working in minimal-env shells.
|
||||
func TestExpandTildePath_FallsBackToUserDatabase(t *testing.T) {
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
setFakeOSHome(t, "")
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
got := expandTildePath("~/foo")
|
||||
want := filepath.Join(u.HomeDir, "foo")
|
||||
if got != want {
|
||||
t.Errorf("expandTildePath(~/foo) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_OSHomeNormalization pins OpenClaw's sentinel
|
||||
// normalisation on the env chain: the literals "undefined" / "null" /
|
||||
// blank-or-whitespace are all treated as unset, so a JS-flavoured
|
||||
// accidentally-stringified env value (e.g. `HOME=undefined` from a
|
||||
// shell wrapper) doesn't end up as a literal directory component when
|
||||
// the user authored `~/secret`. Combined with the user.Current()
|
||||
// fallback further down (see TestOpenClawHome_FallsBackToUserDatabase),
|
||||
// the contract is: a malformed HOME falls through to USERPROFILE first,
|
||||
// and only if that's also unset/sentinel do we go to the user database.
|
||||
func TestOpenClawHome_OSHomeNormalization(t *testing.T) {
|
||||
isolateRuntimeWrites(t)
|
||||
userProfileDir := t.TempDir()
|
||||
homeWinsDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
home string
|
||||
userProfile string
|
||||
want string
|
||||
}{
|
||||
{"HOME=undefined falls through to USERPROFILE", "undefined", userProfileDir, userProfileDir},
|
||||
{"HOME=null falls through to USERPROFILE", "null", userProfileDir, userProfileDir},
|
||||
{"HOME=whitespace falls through to USERPROFILE", " ", userProfileDir, userProfileDir},
|
||||
{"HOME wins over USERPROFILE when both are valid", homeWinsDir, userProfileDir, homeWinsDir},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("HOME", tc.home)
|
||||
t.Setenv("USERPROFILE", tc.userProfile)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
if got := openClawHome(); got != tc.want {
|
||||
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd pins the
|
||||
// deliberate hybrid documented on osHome: with HOME a sentinel literal
|
||||
// and USERPROFILE unset, OpenClaw would fall back to process.cwd();
|
||||
// this implementation falls to the OS user database instead. The
|
||||
// account home is both safer (cwd-independent) and more useful (it is
|
||||
// where the user originally authored `~/...` against), so we prefer it
|
||||
// over either OpenClaw's cwd fallback or a strict reject.
|
||||
func TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd(t *testing.T) {
|
||||
isolateRuntimeWrites(t)
|
||||
u, err := user.Current()
|
||||
if err != nil || u.HomeDir == "" {
|
||||
t.Skip("os/user.Current() unavailable on this runner")
|
||||
}
|
||||
t.Setenv("HOME", "undefined")
|
||||
t.Setenv("USERPROFILE", "")
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
got := openClawHome()
|
||||
if got != u.HomeDir {
|
||||
t.Errorf("openClawHome() = %q, want %q (account home, not cwd)", got, u.HomeDir)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpandTildePath_BackslashPreservedOnPOSIX pins that `~\secret.json`
|
||||
// expands by replacing only the `~` byte, leaving the backslash literally
|
||||
// as part of the filename — matching OpenClaw's regex-replace semantics
|
||||
// (`/^~(?=$|[\\/])/`) rather than going through filepath.Join (which would
|
||||
// drop the backslash on POSIX). On Windows backslash is a real separator,
|
||||
// so the literal-byte invariant doesn't apply.
|
||||
func TestExpandTildePath_BackslashPreservedOnPOSIX(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("backslash is a path separator on Windows; invariant only applies on POSIX")
|
||||
}
|
||||
fakeHome := t.TempDir()
|
||||
setFakeOSHome(t, fakeHome)
|
||||
t.Setenv("OPENCLAW_HOME", "")
|
||||
|
||||
got := expandTildePath(`~\secret.json`)
|
||||
want := fakeHome + `\secret.json`
|
||||
if got != want {
|
||||
t.Errorf("expandTildePath(%q) = %q, want %q (backslash should be preserved as filename byte)", `~\secret.json`, got, want)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ description: "飞书/Lark CLI 共享基础:应用配置初始化、认证登
|
||||
|
||||
首次使用需运行 `lark-cli config init` 完成应用配置。
|
||||
|
||||
当你帮用户初始化配置时,使用background方式使用下面的命令发起配置应用流程,启动后读取输出,从中提取授权链接并发给用户:
|
||||
当你帮用户初始化配置时,使用background方式使用下面的命令发起配置应用流程,启动后读取输出,从中提取授权链接并发给用户。
|
||||
|
||||
**URL 转发规则**:当命令输出 `verification_url`、`verification_uri_complete`、`console_url` 等 URL 字段时,必须将 URL exactly as returned by the CLI 转发给用户,并把它视为不可修改的 opaque string;不要做 URL encode/decode,不要补 `%20`、空格或标点,不要重新拼接 query,不要改写成 Markdown link text,建议用只包含原始 URL 的代码块单独输出。
|
||||
|
||||
```bash
|
||||
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
|
||||
@@ -51,7 +53,7 @@ lark-cli config init --new
|
||||
|
||||
#### Bot 身份(`--as bot`)
|
||||
|
||||
将错误中的 `console_url` 提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
|
||||
#### User 身份(`--as user`)
|
||||
|
||||
@@ -64,7 +66,7 @@ lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推
|
||||
|
||||
#### Agent 代理发起认证(推荐)
|
||||
|
||||
当你作为 AI agent 需要帮用户完成认证时,使用background方式 执行以下命令发起授权流程, 并将授权链接发给用户:
|
||||
当你作为 AI agent 需要帮用户完成认证时,使用background方式 执行以下命令发起授权流程, 并将授权链接原样发给用户:
|
||||
|
||||
```bash
|
||||
# 发起授权(阻塞直到用户授权完成或过期)
|
||||
|
||||
107
skills/lark-slides-creator/SKILL.md
Normal file
107
skills/lark-slides-creator/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: lark-slides-creator
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片创作工作流:从自然语言需求创建、重构、美化完整 PPT,覆盖规划、模板选择、视觉风格、素材规划和创建后验证。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
# slides creator workflow
|
||||
|
||||
> 执行 XML/API 前必须读取 ../lark-slides/SKILL.md 和对应 reference。
|
||||
|
||||
This skill is the natural-language entry point for creating polished presentations. It owns planning, design, template, asset, and quality-validation workflows. It delegates all XML/API execution to [`../lark-slides/SKILL.md`](../lark-slides/SKILL.md).
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this skill when the user asks for:
|
||||
|
||||
- A new complete presentation from a topic, notes, outline, document, meeting, or rough prompt.
|
||||
- Beautification, restructuring, major rewrite, or formal-report polishing.
|
||||
- Template selection or a deck based on a theme, scene, industry, or visual style.
|
||||
- Visual direction, palette, typography, layout system, or executive-ready presentation quality.
|
||||
- Asset planning, image search/download/upload planning, or deciding where visuals belong.
|
||||
- Creation-time and post-creation validation for content completeness and visual quality.
|
||||
|
||||
For a narrow raw XML/API operation, use `lark-slides` directly.
|
||||
|
||||
## Required Execution Dependency
|
||||
|
||||
Before running any `lark-cli slides` command or writing final XML:
|
||||
|
||||
1. Read [`../lark-slides/SKILL.md`](../lark-slides/SKILL.md).
|
||||
2. Read [`../lark-slides/references/xml-schema-quick-ref.md`](../lark-slides/references/xml-schema-quick-ref.md).
|
||||
3. Read the relevant execution reference, such as `lark-slides-create.md`, `lark-slides-media-upload.md`, `lark-slides-replace-slide.md`, or an `xml_presentation.*` API reference.
|
||||
|
||||
Use the execution skill's lint tool from here when XML is available:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Understand the deck goal.
|
||||
Capture topic, audience, page count, source material, language, formality, delivery setting, and any brand/style constraints. If the user gives enough information, proceed with explicit assumptions instead of blocking on questions.
|
||||
|
||||
2. Choose template or custom direction.
|
||||
If the request mentions templates, style, theme, or a common deck scenario, search templates first:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
```
|
||||
|
||||
Offer 2-3 concise candidates when user choice matters. If one template is clearly best for a lightweight request, state the default and continue unless the user asked to choose.
|
||||
|
||||
3. Plan the deck.
|
||||
Build a page-by-page outline with title, role, key message, and intended layout for each slide. For formal reports, make the argument flow explicit: context, evidence, analysis, recommendation, next steps.
|
||||
|
||||
4. Design the visual system.
|
||||
Define palette, typography hierarchy, spacing, page rhythm, chart/table treatment, and recurring elements. Keep slides visual and low-density; do not produce document-like pages.
|
||||
|
||||
5. Plan assets.
|
||||
Decide which pages need screenshots, photos, diagrams, icons, or charts. External images must become local files first, then execution uses `+media-upload` or `@./path` placeholders as described in `lark-slides`.
|
||||
|
||||
6. Generate XML and execute through `lark-slides`.
|
||||
Use template summaries or extracted page slices when helpful, but rewrite all placeholder copy into the user's real content. For complex decks, prefer the two-step create flow from `lark-slides`.
|
||||
|
||||
7. Validate after creation.
|
||||
Read the created presentation XML with `xml_presentations get`, confirm page count and expected content, run lint when possible, then fix issues with `+replace-slide` or raw slide APIs.
|
||||
|
||||
## Template Workflow
|
||||
|
||||
Template assets live in this skill:
|
||||
|
||||
- [`references/template-catalog.md`](references/template-catalog.md)
|
||||
- [`references/template-index.json`](references/template-index.json)
|
||||
- [`assets/templates/`](assets/templates/)
|
||||
- [`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
|
||||
Machine-first commands:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py search --query "工作汇报" --tone light --limit 3
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py summarize --template office--work_report --label 内容
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py extract --template office--work_report --label 封面 --out /tmp/work-report-cover.xml
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Search using the user's original wording.
|
||||
- Show only 2-3 candidate templates unless the user asks for the full catalog.
|
||||
- Summarize a target page type before extracting XML.
|
||||
- Do not read entire template XML files by default.
|
||||
- Reuse theme, spacing, and structure; do not copy placeholder text.
|
||||
|
||||
## References
|
||||
|
||||
| Reference | Purpose |
|
||||
| --- | --- |
|
||||
| [planning-layer.md](references/planning-layer.md) | Deck planning and outline workflow. |
|
||||
| [visual-planning.md](references/visual-planning.md) | Visual style and layout design guidance. |
|
||||
| [asset-planning.md](references/asset-planning.md) | Asset selection, local-file, and upload planning. |
|
||||
| [template-catalog.md](references/template-catalog.md) | Template matching catalog. |
|
||||
| [slide-templates.md](references/slide-templates.md) | Copyable slide XML patterns for creation. |
|
||||
| [validation-checklist.md](references/validation-checklist.md) | Creation quality and post-create validation checklist. |
|
||||
21
skills/lark-slides-creator/references/asset-planning.md
Normal file
21
skills/lark-slides-creator/references/asset-planning.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Asset Planning
|
||||
|
||||
Use this when a deck needs screenshots, photos, diagrams, logos, icons, or chart data.
|
||||
|
||||
## Asset Plan
|
||||
|
||||
For each asset, record:
|
||||
|
||||
- Slide number and purpose.
|
||||
- Asset type: screenshot, product image, chart, diagram, logo, icon, photo.
|
||||
- Source: provided file, generated file, downloaded file, or chart from data.
|
||||
- Local path under the current working directory.
|
||||
- Intended placement and dimensions.
|
||||
|
||||
## Rules
|
||||
|
||||
- Slides XML cannot use HTTP(S) image URLs directly.
|
||||
- For a new deck using `+create --slides`, local image placeholders can use `src="@./path.png"`.
|
||||
- For existing decks or raw slide APIs, upload first with `slides +media-upload`, then use the returned `file_token`.
|
||||
- Keep source files inside the current working directory or a safe project subdirectory.
|
||||
- Check image dimensions and file size before upload; slides media upload limit is 20 MB.
|
||||
32
skills/lark-slides-creator/references/planning-layer.md
Normal file
32
skills/lark-slides-creator/references/planning-layer.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Slides Planning Layer
|
||||
|
||||
Use this before writing XML for a full presentation or a major rewrite.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Goal: what decision, update, teaching outcome, or story the deck must support.
|
||||
- Audience: executives, customers, internal team, interview panel, students, or general readers.
|
||||
- Constraints: page count, language, source material, deadline, brand rules, required sections.
|
||||
- Success criteria: what the user should be able to inspect after creation.
|
||||
|
||||
## Output Outline
|
||||
|
||||
Use this compact structure:
|
||||
|
||||
```text
|
||||
Title: <deck title>
|
||||
Audience: <audience>
|
||||
Style: <visual direction or selected template>
|
||||
Slides:
|
||||
1. <slide title> - <message> - <layout intent>
|
||||
2. ...
|
||||
```
|
||||
|
||||
For formal reports, prefer this flow: cover, context, key findings, supporting evidence, implications, recommendations, next steps, closing.
|
||||
|
||||
## Rules
|
||||
|
||||
- Each slide gets one primary message.
|
||||
- Avoid document-like density; split overloaded pages.
|
||||
- Make charts or tables serve a stated point.
|
||||
- Confirm template choice when multiple good candidates would lead to materially different decks.
|
||||
@@ -10,26 +10,26 @@
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<主题>" --limit 3`,根据用户描述的 **场景、风格、色调** 做初筛
|
||||
1. 先运行 `python3 skills/lark-slides-creator/scripts/template_tool.py search --query "<主题>" --limit 3`,根据用户描述的 **场景、风格、色调** 做初筛
|
||||
2. 整理出 **2-3 个**最匹配的用户可选模板候选;优先选场景强相关模板,没有明显场景模板时再用标 ⭐ 的通用模板兜底
|
||||
3. 用户选定后,再锁定 **1-2 个**最匹配的模板作为实际参考
|
||||
4. 先看模板下方的 **页型索引**,锁定你真正需要的页型:封面 / 目录 / 分节 / 内容 / 结尾
|
||||
5. 优先运行 `template_tool.py summarize` 查看 `<theme>` / 页型摘要;只有需要具体布局骨架时,再运行 `template_tool.py extract`
|
||||
6. 从模板中提取并复用:`<theme>` 配色、页面流、shape 排列布局、装饰元素风格
|
||||
7. 将用户的实际内容填充到模板的结构框架中,**不要照搬模板的占位文字**
|
||||
8. 创建前运行 `layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
|
||||
8. 创建前运行 `python3 skills/lark-slides-creator/scripts/layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
|
||||
|
||||
### 脚本快捷命令
|
||||
|
||||
```bash
|
||||
# 先找候选模板
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "工作汇报" --tone light --limit 3
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py search --query "工作汇报" --tone light --limit 3
|
||||
|
||||
# 看指定页型的紧凑摘要
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template office--work_report --label 内容
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py summarize --template office--work_report --label 内容
|
||||
|
||||
# 只裁切目标页型,避免把整份 XML 拉进上下文
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template office--work_report --label 封面 --out /tmp/work-report-cover.xml
|
||||
python3 skills/lark-slides-creator/scripts/template_tool.py extract --template office--work_report --label 封面 --out /tmp/work-report-cover.xml
|
||||
```
|
||||
|
||||
如果脚本路径不可用,按这个顺序手动降级:
|
||||
@@ -0,0 +1,25 @@
|
||||
# Slides Creation Validation Checklist
|
||||
|
||||
Use this after generating XML and again after creating or editing the deck.
|
||||
|
||||
## Before API Execution
|
||||
|
||||
- XML is well-formed.
|
||||
- User text is escaped: `&`, `<`, and `>` are safe.
|
||||
- Each slide has one clear message.
|
||||
- Text boxes are sized for expected content.
|
||||
- Images use `@./local-path` only where `+create --slides` supports it; otherwise they use `file_token`.
|
||||
- Run execution-layer lint when XML is in a file:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides-creator/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
## After Creation
|
||||
|
||||
- Record `xml_presentation_id`.
|
||||
- Read the full deck with `xml_presentations get`.
|
||||
- Confirm expected page count and page order.
|
||||
- Confirm key titles, body text, metrics, and image elements exist.
|
||||
- Check for blank pages, missing text, truncated shell arguments, unresolved `@` paths, and wrong image `src`.
|
||||
- Fix localized issues with `+replace-slide`; only delete/recreate a page when the whole structure is wrong.
|
||||
22
skills/lark-slides-creator/references/visual-planning.md
Normal file
22
skills/lark-slides-creator/references/visual-planning.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Visual Planning
|
||||
|
||||
Use this to define the deck's visual system before generating slide XML.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Palette: background, primary accent, secondary accent, text, muted text, border.
|
||||
- Typography: title, section heading, body, caption, metric number.
|
||||
- Layout rhythm: margins, grid, recurring title position, footer treatment.
|
||||
- Components: cards, callouts, timelines, charts, tables, quote blocks, section dividers.
|
||||
|
||||
## Guidance
|
||||
|
||||
- Business reports should be quiet, readable, and scannable.
|
||||
- Product or technology decks can use stronger contrast, but keep hierarchy clear.
|
||||
- Use repeated structure across related slides.
|
||||
- Keep text inside predictable bounds; leave enough whitespace for rendering variance.
|
||||
- Do not rely on external image URLs in XML. Images must become `file_token` values through the execution workflow.
|
||||
|
||||
## XML Note
|
||||
|
||||
Before writing XML, read `../lark-slides/references/xml-schema-quick-ref.md`. Gradient fills must use `rgba()` stops with percentages.
|
||||
@@ -1,525 +1,163 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。"
|
||||
description: "飞书幻灯片执行层:通过 Slides XML/API 读取、创建、删除、替换幻灯片页面,处理 URL/wiki token、媒体上传、XML schema、格式校验与排障。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
# slides (v1)
|
||||
# slides execution layer
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
> 创建完整 PPT、设计、美化、模板、素材、正式汇报场景请使用 lark-slides-creator。本 skill 只负责 XML/API 执行层。
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
**CRITICAL — 生成或修改任何 XML 之前,MUST 先读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)。不要凭记忆猜测 XML 结构。**
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
**CRITICAL — `references/slides_xml_schema_definition.xml` 是 Slides XML 协议的唯一权威来源;Markdown reference 只是摘要。若两者或 `lark-cli schema` 输出不一致,以 schema 和 CLI 为准。**
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。生成本地 XML 后,如可运行 Python,MUST 先用 [`scripts/layout_lint.py`](scripts/layout_lint.py) 检查 XML well-formed、重叠/越界/文本高度风险,再创建或追加页面。它不是完整 XSD schema 校验。**
|
||||
## Scope
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
Use this skill for low-level execution tasks:
|
||||
|
||||
## 身份选择
|
||||
- Create an empty presentation or add raw slide XML.
|
||||
- Read presentation or slide XML.
|
||||
- Delete slides.
|
||||
- Replace or insert existing slide blocks.
|
||||
- Upload local media and use returned `file_token` in XML.
|
||||
- Resolve `/slides/` URL tokens and `/wiki/` tokens.
|
||||
- Check XML format, schema rules, and common API errors.
|
||||
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
Do not use this skill as the primary entry for planning, visual design, template selection, asset planning, or full-deck creation. Route those requests to `lark-slides-creator`, then return here only for XML/API execution.
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
## Identity
|
||||
|
||||
Slides are usually user-owned content. Default to explicit `--as user` for slides commands.
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain slides
|
||||
```
|
||||
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
Use `--as bot` only when the user explicitly asks for app/bot identity or the workflow intentionally creates bot-owned resources. If access fails, first check that the command did not accidentally use the wrong identity.
|
||||
|
||||
**执行规则**:
|
||||
## URL And Wiki Tokens
|
||||
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
一条命令创建包含页面内容的 PPT(推荐):
|
||||
| URL | Token | Handling |
|
||||
| --- | --- | --- |
|
||||
| `/slides/<token>` | `xml_presentation_id` | Use the path token directly. |
|
||||
| `/wiki/<token>` | `wiki_token` | Resolve first with `wiki.spaces.get_node`; use `node.obj_token` only when `node.obj_type` is `slides`. |
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --title "演示文稿标题" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245,245,245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>页面标题</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"200\"><content textType=\"body\"><p>正文内容</p><ul><li><p>要点一</p></li><li><p>要点二</p></li></ul></content></shape></data></slide>"
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"obj_token"}'
|
||||
```
|
||||
|
||||
`+replace-slide` and `+media-upload` can parse slides/wiki URLs. Raw API commands still require the real `xml_presentation_id`.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Reference | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `slides +create` | [lark-slides-create.md](references/lark-slides-create.md) | Create a presentation; optionally add pages with `--slides`; supports local image placeholders in `+create --slides`. |
|
||||
| `slides +media-upload` | [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | Upload a local image to a presentation and return a `file_token`. |
|
||||
| `slides +replace-slide` | [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | Replace or insert blocks on an existing slide without changing page order. |
|
||||
|
||||
Prefer shortcuts when they cover the operation, especially `+replace-slide` for existing-slide edits.
|
||||
|
||||
## API Commands
|
||||
|
||||
Always inspect schema before raw API calls:
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method>
|
||||
lark-cli slides <resource> <method> --as user --params '{}' --data '{}'
|
||||
```
|
||||
|
||||
Core resources:
|
||||
|
||||
| Resource | Method | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `xml_presentations` | `get` | Read full presentation XML and metadata. |
|
||||
| `xml_presentation.slide` | `create` | Add one slide XML page. |
|
||||
| `xml_presentation.slide` | `delete` | Delete a slide; a presentation must keep at least one page. |
|
||||
| `xml_presentation.slide` | `get` | Read one slide XML. |
|
||||
| `xml_presentation.slide` | `replace` | Low-level block replace/insert API; prefer `+replace-slide` unless you need raw control. |
|
||||
|
||||
## Creation Paths
|
||||
|
||||
For simple XML, `+create --slides` is concise:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create --as user --title "Demo" --slides '[
|
||||
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(248,250,252)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>Title</p></content></shape></data></slide>"
|
||||
]'
|
||||
```
|
||||
|
||||
也可以分两步(先创建空白 PPT,再逐页添加),详见 [+create 参考文档](references/lark-slides-create.md)。
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 适合简单页面批量创建,但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符,shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层是“先创建空白 PPT,再逐页调用 `xml_presentation.slide.create`”。这不是原子操作;中途某一页失败时,前面已创建成功的页面会保留。skill 必须把这种“部分成功”风险提前告诉用户,并在失败后先记录 `xml_presentation_id`,回读确认当前状态,再决定是否在现有 PPT 上继续修复或追加。
|
||||
|
||||
> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。
|
||||
|
||||
## 执行前必做
|
||||
|
||||
> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
### 必读(每次创建前)
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML 元素和属性速查,必读** |
|
||||
|
||||
### 选读(需要时查阅)
|
||||
|
||||
| 场景 | 文档 |
|
||||
|------|------|
|
||||
| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) |
|
||||
| 需要快速筛模板、做低成本路由 | [`scripts/template_tool.py search`](scripts/template_tool.py) |
|
||||
| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) |
|
||||
| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template_tool.py`](scripts/template_tool.py) |
|
||||
| 需要做本地布局风险检查 | [`scripts/layout_lint.py`](scripts/layout_lint.py) |
|
||||
| 需要 CLI 调用示例 | [examples.md](references/examples.md) |
|
||||
| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) |
|
||||
| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema) |
|
||||
| 需要编辑已有 PPT 的单个页面 | [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) |
|
||||
| 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) |
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
For complex XML, long text, many special characters, Chinese paragraphs, images, or many pages, create an empty presentation first and add slides one by one. `+create --slides` is not atomic; if a later slide fails, earlier slides may already exist. Record `xml_presentation_id` and read the deck before continuing.
|
||||
|
||||
```bash
|
||||
# 1. 搜索候选:把用户原始需求整句放进 --query,不要只放手动提炼的短词
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
lark-cli slides +create --as user --title "Demo"
|
||||
|
||||
# 2. 锁定模板后先看页型摘要
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
|
||||
# 3. 只有需要复用布局骨架时才裁切 XML
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
|
||||
# 4. 生成待创建 XML 后先做布局风险检查
|
||||
python3 skills/lark-slides/scripts/layout_lint.py --input /tmp/presentation.xml
|
||||
```
|
||||
|
||||
执行规则:
|
||||
|
||||
1. `search --query` 使用用户原始描述;如用户明确风格,再额外加 `--tone light|dark|colorful` 或 `--formality formal|casual|creative`。
|
||||
2. 候选展示只给 2-3 个,包含模板名、适用场景、风格/色调、推荐理由;不要把完整目录贴给用户。
|
||||
3. 锁定模板后,复用 `<theme>`、配色、页面流、布局骨架;所有占位文案都必须改写为用户真实内容。
|
||||
4. `layout_lint.py` 有 error 时先修 XML,不要提交创建;只有 warning 时,检查是否是可接受的装饰/背景误报。
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清用户需求:主题、受众、页数、风格偏好
|
||||
- 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由)
|
||||
- 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选
|
||||
- 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底
|
||||
- 如果用户没有明确风格,根据主题推荐(见下方风格判断表)
|
||||
- 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景:
|
||||
· 优先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3` 做低成本模板匹配
|
||||
· 需要人类可读说明时,再读 template-catalog.md 组织候选文案
|
||||
· 锁定模板后,优先运行 `template_tool.py summarize` 看 `<theme>` / 页型摘要;需要具体布局时,再用 `template_tool.py extract`
|
||||
· 复用模板的 theme、配色、页面流、布局骨架,不要照搬占位文案
|
||||
· `references/template-index.json` 只是脚本缓存/轻量路由索引,`assets/templates/*.xml` 是机器资源;除非用户明确要求审计原始模板,否则不要直接读取
|
||||
- 读取 XML Schema 参考:
|
||||
· xml-schema-quick-ref.md — 元素和属性速查
|
||||
· xml-format-guide.md — 详细结构与示例
|
||||
· slides_demo.xml — 真实 XML 示例
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 创建
|
||||
- 生成大纲前,先确认用户是否采用推荐模板;轻量任务且候选中有明显最佳匹配时,可在大纲里声明“默认基于 <template-id> 改写”并继续,但正式创建前必须给用户改选机会
|
||||
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
|
||||
- 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写”
|
||||
- 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择
|
||||
- 先判断创建方式:
|
||||
· 简单 XML:可用 `slides +create --slides '[...]'` 一步创建
|
||||
· 复杂 XML:优先先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
· 超过 10 页:默认使用两步创建,避免单次输入过长
|
||||
- 含本地图片:
|
||||
· 新建带图 PPT —— 在 slide XML 里写 <img src="@./pic.png" .../>,
|
||||
+create 会自动上传并替换为 file_token(详见 lark-slides-create.md)
|
||||
· 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID`
|
||||
拿到 file_token,再用它写进 slide XML 调 xml_presentation.slide.create
|
||||
· 给已有页加图 —— 两步:① `slides +media-upload` 拿 file_token
|
||||
② `slides +replace-slide --parts '[{"action":"block_insert","insertion":"<img src=\"<file_token>\" .../>"}]'`
|
||||
不动其他元素,不要再整页重建(完整示例见 lark-slides-edit-workflows.md 的 block_insert 章节)
|
||||
· 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png);
|
||||
绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行
|
||||
- 每页 slide 需要完整的 XML:背景、文本、图形、配色
|
||||
- 复杂元素(table、chart)需参考 XSD 原文
|
||||
- 创建前必须做 XML 自检:
|
||||
· 检查特殊字符是否按 XML 规则转义:文本节点和属性值里的裸 `& -> &`;文本里的 `< -> <`、`> -> >`。例如 `Q&A -> Q&A`,URL 属性 `a=1&b=2 -> a=1&b=2`
|
||||
· 属性值里的双引号必须转义或改为外层安全包装,避免 shell 和 JSON 双重截断
|
||||
· 确认所有标签闭合,且 `<slide>` 直接子元素只包含 `<style>`、`<data>`、`<note>`
|
||||
· 如果内容里同时出现中文、大段文本、复杂布局、较多特殊字符,默认不要走 `--slides '[...]'`,直接改用两步创建法
|
||||
· 如果 XML 已落到本地文件且可运行 Python,先执行 `layout_lint.py --input <file>`;它会先检查 XML well-formed 再检查布局风险,但不等价于完整 XSD schema 校验;有 error 先修复再创建
|
||||
- 如果使用模板生成页面,先复用模板骨架再填内容,不要直接复制模板中的长段占位文本
|
||||
|
||||
Step 3: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML 做创建后验证,确认:
|
||||
· 页数是否正确?
|
||||
· 每页 `<data>` 是否包含预期的 `<shape>` / `<img>` / 其他元素?
|
||||
· 文本内容是否完整,是否有被截断、丢失、空白区域?
|
||||
· 关键布局坐标和尺寸是否合理,是否出现明显重叠?
|
||||
· 配色是否统一?字号层级是否合理?
|
||||
- 如果本地有 Python 3,运行
|
||||
`python3 skills/lark-slides/scripts/layout_lint.py --input presentation.xml`
|
||||
做重叠、越界、页脚碰撞、文本高度风险检查;有 error 先修复再交付
|
||||
- 如果创建过程中失败:
|
||||
· 先保留并记录 `xml_presentation_id`,不要假设失败代表什么都没创建
|
||||
· 先判断是否已有部分页面写入,再决定是否在现有 PPT 上修复后继续追加
|
||||
· 优先排查当前失败页:先看该页 XML,再检查是否存在未转义 `&`、错误引号、标签未闭合、shell 传参截断
|
||||
- 局部问题 → 用 `+replace-slide` 块级修正;整页结构要改 → `slide.delete` 旧页 + `slide.create` 新页
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
### 创建后验证
|
||||
|
||||
创建成功不等于内容正确。创建完 PPT 后,**必须**读取全文 XML 校验结果:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- [ ] 页数是否与预期一致
|
||||
- [ ] 每页 `<data>` 中是否包含所有预期元素
|
||||
- [ ] 文本内容是否完整,没有被 shell 截断或转义损坏
|
||||
- [ ] 白底内容区、卡片区、图文区等关键布局是否实际生成
|
||||
- [ ] 坐标、宽高是否合理,是否出现堆叠或越界
|
||||
|
||||
发现问题时:
|
||||
|
||||
1. 不要假设“创建成功就代表渲染正确”
|
||||
2. 先读取问题页的 XML,确认是生成问题还是传参损坏
|
||||
3. 删除问题页后重新添加;复杂页面优先改用两步创建法
|
||||
|
||||
### 最小验收清单
|
||||
|
||||
创建完成后,默认按下面顺序验收,不要省略:
|
||||
|
||||
1. 记录 `xml_presentation_id`
|
||||
2. 确认返回的 `slides_added` 或实际页数是否符合预期
|
||||
3. 立即执行 `xml_presentations get`
|
||||
4. 检查标题、关键页面、关键文本是否存在
|
||||
5. 检查是否有明显空白页、内容缺失、页序错误
|
||||
6. 再决定是否向用户交付 URL 和后续编辑建议
|
||||
|
||||
推荐最小闭环:
|
||||
|
||||
```bash
|
||||
# 创建
|
||||
lark-cli slides +create --as user --title "Demo" --slides '[...]'
|
||||
|
||||
# 立即回读
|
||||
lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
## XML 自检与排障
|
||||
|
||||
在真正创建前,至少做下面 4 项检查:
|
||||
|
||||
- [ ] 特殊字符已转义:正文和标题里的 `&`、`<`、`>` 不能裸写;属性值里的裸 `&` 也必须写成 `&`
|
||||
- [ ] 属性引号安全:XML 属性、shell 引号、JSON 字符串包装之间没有互相打断
|
||||
- [ ] 结构合法:`<slide>` 下只放 `<style>`、`<data>`、`<note>`,文本都在 `<content>` 内
|
||||
- [ ] 路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用
|
||||
|
||||
高频失败信号和处理顺序:
|
||||
|
||||
1. `invalid param` / 某一页创建失败
|
||||
2. 先检查失败页是否含未转义 `&` / `<` / `>`:`Q&A -> Q&A`,属性 URL `a=1&b=2 -> a=1&b=2`
|
||||
3. 再检查标签闭合、属性引号、`<content>` 结构
|
||||
4. 如果是 `--slides '[...]'`,怀疑 shell 截断时直接切两步创建法
|
||||
5. 创建后无论成功失败,都优先记录 `xml_presentation_id` 并回读确认是否已有部分页面写入
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
lark-cli slides xml_presentation.slide create --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级
|
||||
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0"><data/></slide>' '{slide:{content:$content}}')"
|
||||
```
|
||||
|
||||
### 风格快速判断表
|
||||
To insert before an existing page, put `before_slide_id` in `--data`, not in `--params`.
|
||||
|
||||
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
## Media Upload
|
||||
|
||||
| 场景/主题 | 推荐风格 | 背景 | 主色 | 文字色 |
|
||||
|----------|---------|------|------|-------|
|
||||
| 科技/AI/产品 | 深色科技风 | 深蓝渐变 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)` | 蓝色系 `rgb(59,130,246)` | 白色 |
|
||||
| 商务汇报/季度总结 | 浅色商务风 | 浅灰 `rgb(248,250,252)` | 深蓝 `rgb(30,60,114)` | 深灰 `rgb(30,41,59)` |
|
||||
| 教育/培训 | 清新明亮风 | 白色 `rgb(255,255,255)` | 绿色系 `rgb(34,197,94)` | 深灰 `rgb(51,65,85)` |
|
||||
| 创意/设计 | 渐变活力风 | 紫粉渐变 `linear-gradient(135deg,rgba(88,28,135,1) 0%,rgba(190,24,93,1) 100%)` | 粉紫色系 | 白色 |
|
||||
| 周报/日常汇报 | 简约专业风 | 浅灰 `rgb(248,250,252)` + 顶部彩色渐变条 | 蓝色 `rgb(59,130,246)` | 深色 `rgb(15,23,42)` |
|
||||
| 用户未指定 | 默认简约专业风 | 同上 | 同上 | 同上 |
|
||||
Slides XML image `src` must be a Lark `file_token`; do not use external HTTP(S) URLs.
|
||||
|
||||
### 页面布局建议
|
||||
- New deck with `+create --slides`: `src="@./local.png"` is allowed and the shortcut uploads it.
|
||||
- Existing deck or raw `slide.create`: run `slides +media-upload` first, then write `src="<file_token>"`.
|
||||
- Existing slide edit: upload first, then use `+replace-slide` with `block_insert` or `block_replace`.
|
||||
|
||||
| 页面类型 | 布局要点 |
|
||||
|---------|---------|
|
||||
| 封面页 | 居中大标题 + 副标题 + 底部信息,背景用渐变或深色 |
|
||||
| 数据概览页 | 指标卡片横排(rect 背景 + 大号数字 + 小号说明),下方列表或图表 |
|
||||
| 内容页 | 左侧竖线装饰 + 标题,下方分栏或列表 |
|
||||
| 对比/表格页 | table 元素或并列卡片,表头深色背景白字 |
|
||||
| 图表页 | chart 元素(column/line/pie),配合文字说明 |
|
||||
| 结尾页 | 居中感谢语 + 装饰线,风格与封面呼应 |
|
||||
Local paths must be safe paths under the current working directory. The upload limit is 20 MB.
|
||||
|
||||
### 大纲模板
|
||||
## XML Rules
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
- `<slide>` direct children are only `<style>`, `<data>`, and `<note>`.
|
||||
- Text belongs inside `<content><p>...</p></content>`.
|
||||
- Escape raw text before writing XML: `&` becomes `&`, text `<` becomes `<`, and text `>` becomes `>`.
|
||||
- Gradient fills require `rgba()` stops with percentages, for example `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`.
|
||||
- For `xml_presentation.slide.replace`, `block_replace` needs the target block id and text shapes need `<content/>`; `+replace-slide` injects the required wrapper details.
|
||||
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
## Validation
|
||||
|
||||
模板:[未使用模板 / <category>/<template>.xml(推荐原因)]
|
||||
This execution skill validates at the XML/API layer. Before execution, check XML well-formedness, escaping, request body shape, and `lark-cli schema` output. Visual layout quality checks belong to creator workflows, not this execution layer.
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
## Troubleshooting
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
| Symptom | Likely Cause | Next Action |
|
||||
| --- | --- | --- |
|
||||
| `400` XML or wrapper error | Bad XML or wrong `--data` shape | Check escaping, tag closure, and `lark-cli schema`. |
|
||||
| `403` permission denied | Wrong identity or missing scope | Confirm `--as user` vs `--as bot`; re-run auth for slides scope. |
|
||||
| `404` presentation/slide not found | Wrong token or unresolved wiki URL | Resolve wiki token or re-read current presentation. |
|
||||
| `1061002` media params error | Raw upload API used incorrectly | Use `slides +media-upload`; slides parent type is `slide_file`. |
|
||||
| `1061004` forbidden | Current identity cannot edit target deck | Use the owner identity or share the deck with the bot/user. |
|
||||
| `3350001` catch-all validation failure | XML not well-formed, bad replace wrapper, missing `<content/>`, or unescaped text | Run lint, inspect failed page XML, and prefer `+replace-slide` for block edits. |
|
||||
| `3350002` stale revision | `revision_id` is newer than current | Use `-1` or re-read the presentation and retry. |
|
||||
| Created deck has blank/missing pages | Shell/JSON argument truncation or escaping issue | Read back XML, then continue with two-step `slide.create`. |
|
||||
| Image does not show | `src` is URL or unresolved `@path` | Upload and replace with a `file_token`. |
|
||||
|
||||
### 常用 Slide XML 模板
|
||||
## References
|
||||
|
||||
可直接复制使用的模板(封面页、内容页、数据卡片页、结尾页):[slide-templates.md](references/slide-templates.md)
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### URL 格式与 Token
|
||||
|
||||
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|
||||
|----------|------|-----------|----------|
|
||||
| `/slides/` | `https://example.larkoffice.com/slides/xxxxxxxxxxxxx` | `xml_presentation_id` | URL 路径中的 token 直接作为 `xml_presentation_id` 使用 |
|
||||
| `/wiki/` | `https://example.larkoffice.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
|
||||
|
||||
> `+replace-slide` 和 `+media-upload` shortcut 会自动解析以上两种 URL;直接调用原生 API 时仍需手动解析 wiki 链接。
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、幻灯片等不同类型的文档。**不能直接假设 URL 中的 token 就是 `xml_presentation_id`**,必须先查询实际类型和真实 token。
|
||||
|
||||
#### 处理流程
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
|
||||
```
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型,幻灯片对应 `slides`
|
||||
- `node.obj_token`:**真实的演示文稿 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
|
||||
3. **确认 `obj_type` 为 `slides` 后,使用 `obj_token` 作为 `xml_presentation_id`**
|
||||
|
||||
#### 查询示例
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --as user --params '{"token":"wikcnxxxxxxxxx"}'
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "slides",
|
||||
"obj_token": "xxxxxxxxxxxx",
|
||||
"title": "2026 产品年度总结",
|
||||
"node_type": "origin",
|
||||
"space_id": "1234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 用 obj_token 读取幻灯片内容
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"xxxxxxxxxxxx"}'
|
||||
```
|
||||
|
||||
### 资源关系
|
||||
|
||||
```text
|
||||
Wiki Space (知识空间)
|
||||
└── Wiki Node (知识库节点, obj_type: slides)
|
||||
└── obj_token → xml_presentation_id
|
||||
|
||||
Slides (演示文稿)
|
||||
├── xml_presentation_id (演示文稿唯一标识)
|
||||
├── revision_id (版本号)
|
||||
└── Slide (幻灯片页面)
|
||||
└── slide_id (页面唯一标识)
|
||||
```
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传),bot 模式自动授权 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### xml_presentations
|
||||
|
||||
- `get` — 读取演示文稿全文信息,XML 格式返回
|
||||
|
||||
### xml_presentation.slide
|
||||
|
||||
- `create` — 在指定 XML 演示文稿下创建页面
|
||||
- `delete` — 在指定 XML 演示文稿下删除页面
|
||||
- `get` — 获取指定 XML 演示文稿的单个页面 XML 内容
|
||||
- `replace` — 对指定 XML 演示文稿页面进行元素级别的局部替换
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先定模板/风格并出大纲再动手**:如果需求可匹配模板,先给用户 2-3 个模板候选;模板或自定义风格确定后,再生成大纲交给用户确认,避免返工
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) |
|
||||
| `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `slides +replace-slide` | `slides:presentation:update`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `xml_presentations.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` |
|
||||
| `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` |
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
## 常见错误速查
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 |
|
||||
| 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` |
|
||||
| 创建成功但页面空白/内容缺失/布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建:先 `slides +create`,再用 `jq -n` 包装 `xml_presentation.slide.create` 逐页添加,并在创建后立即读取 XML 验证 |
|
||||
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
|
||||
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
|
||||
| 403 | 权限不足 | 检查是否拥有对应的 scope |
|
||||
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
|
||||
| 1061002 | params error(媒体上传时) | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
|
||||
| 1061004 | forbidden:当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 `xml_presentation.slide.replace` 失败(catch-all) | 优先检查未转义 `&` / `<` / `>`:`Q&A -> Q&A`,属性 URL `a=1&b=2 -> a=1&b=2`;运行 `layout_lint.py --input <file>` 定位行列和上下文;再检查 replace 场景的 `block_id` / `<content/>` / 坐标 |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
|
||||
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
|
||||
|
||||
## 创建前自查
|
||||
|
||||
逐页生成 XML 前,快速检查:
|
||||
|
||||
- [ ] 每页背景色/渐变是否设置?风格是否与整体一致?
|
||||
- [ ] 标题用大字号(28-48),正文用小字号(13-16),层级分明?
|
||||
- [ ] 同类元素配色一致?(如所有指标卡片同色系、所有正文同色)
|
||||
- [ ] 装饰元素(分割线、色块、竖线)颜色是否与主色协调?
|
||||
- [ ] 文本框尺寸是否足够容纳内容?(宽度 × 高度)
|
||||
- [ ] shape 的 `type` 是否正确?(文本框用 `text`,装饰用 `rect`)
|
||||
- [ ] XML 标签是否全部正确闭合?特殊字符(`&`、`<`、`>`)是否转义?
|
||||
|
||||
## 症状 → 修复表
|
||||
|
||||
| 看到的问题 | 改什么 |
|
||||
|-----------|--------|
|
||||
| 文字被截断/看不全 | 增大 shape 的 `width` 或 `height` |
|
||||
| 元素重叠 | 调整 `topLeftX`/`topLeftY`,拉开间距 |
|
||||
| 页面大面积空白 | 缩小元素间距,或增加内容填充 |
|
||||
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
|
||||
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
|
||||
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 |
|
||||
| 图片被裁掉一部分 | `<img>` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 |
|
||||
| 只想改某页的单个元素(文字/图片/形状) | 用 `+replace-slide` 块级替换,不要整页重建 |
|
||||
| 想给已有页加一张图(不动原有元素) | ① `+media-upload` 拿 `file_token` ② `+replace-slide` 用 `block_insert` 插入 `<img src="<file_token>" .../>`;不要再用 "整页 create + delete" 的老流程 |
|
||||
| 新插入的 `<img>` 挡住/重叠原有元素 | `slide.get` 读原页,对照已有块的 `topLeftX/Y/width/height` 挑空白位置;空间不够就在同一批 `--parts` 里先 `block_replace` 缩小/挪动现有块再 `block_insert` 图片 |
|
||||
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 |
|
||||
| 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) |
|
||||
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
|
||||
| API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 |
|
||||
| API 返回 3350001 | `block_replace` 根元素缺 `id=<block_id>` 或 `<shape>` 缺 `<content/>`,详见 replace-slide 文档 |
|
||||
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src |
|
||||
| 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`(slides 唯一接受值);不要手拼,用 `slides +media-upload` |
|
||||
|
||||
## 参考文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut:创建 PPT(支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片)** |
|
||||
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut:上传本地图片,返回 `file_token`** |
|
||||
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | **+replace-slide Shortcut:块级替换/插入,含合法根元素速查与 3350001 排错** |
|
||||
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | 编辑已有页面的读-改-写流程与 action 决策树 |
|
||||
| [template-index.json](references/template-index.json) | **脚本缓存/轻量路由索引:由 `template_tool.py search` 使用,不是默认阅读入口** |
|
||||
| [template-catalog.md](references/template-catalog.md) | **按场景/色调匹配现成 PPT 模板,并定位到页型范围** |
|
||||
| [`scripts/template_tool.py`](scripts/template_tool.py) | **可选 Python 辅助脚本:`search` / `summarize` / `extract`,支持 `--layout-tag` 与 `extract --with-summary`** |
|
||||
| [`scripts/layout_lint.py`](scripts/layout_lint.py) | **本地预检脚本:先检查 XML well-formed,再检测重叠、越界、页脚碰撞、文本高度风险;不是完整 XSD schema 校验** |
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
|
||||
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
|
||||
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |
|
||||
| [examples.md](references/examples.md) | CLI 调用示例 |
|
||||
| [slides_demo.xml](references/slides_demo.xml) | 真实 PPT 的完整 XML |
|
||||
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | **完整 Schema 定义**(唯一协议依据) |
|
||||
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | 读取 PPT 命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | 添加幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | 删除幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | 读取单个幻灯片命令详情 |
|
||||
| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | 原生 slide.replace API 命令详情 |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
| Reference | Purpose |
|
||||
| --- | --- |
|
||||
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | Required XML element and attribute quick reference. |
|
||||
| [xml-format-guide.md](references/xml-format-guide.md) | Detailed XML structure and examples. |
|
||||
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | Full XML schema definition. |
|
||||
| [lark-slides-create.md](references/lark-slides-create.md) | `+create` shortcut. |
|
||||
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | `+media-upload` shortcut. |
|
||||
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | `+replace-slide` shortcut. |
|
||||
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | Existing-slide read/modify/write workflows. |
|
||||
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | Raw presentation read API. |
|
||||
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | Raw slide create API. |
|
||||
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | Raw slide delete API. |
|
||||
| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | Raw slide get API. |
|
||||
| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | Raw slide replace API. |
|
||||
| [examples.md](references/examples.md) | CLI examples. |
|
||||
| [slides_demo.xml](references/slides_demo.xml) | Example presentation XML. |
|
||||
|
||||
@@ -178,7 +178,7 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
| 400 | XML 格式错误 | 检查 `slide.content` 是否是完整 `<slide>` 元素 |
|
||||
| 400 | 请求体结构错误 | 检查是否按 `slide.content` 和 `before_slide_id` 包装 |
|
||||
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:update` 或 `slides:presentation:write_only` scope |
|
||||
| 3350001 | XML 非 well-formed 或服务端参数校验失败 | 优先检查未转义字符:文本 `Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`;创建前运行 `python3 skills/lark-slides/scripts/layout_lint.py --input <file>` 获取行列和上下文 |
|
||||
| 3350001 | XML 非 well-formed 或服务端参数校验失败 | 优先检查未转义字符:文本 `Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`;再检查标签闭合、命名空间、`<content>` 结构和请求体包装 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -188,7 +188,7 @@ lark-cli slides xml_presentation.slide create --as user \
|
||||
4. **fill / border 写法**: 颜色填充使用 `<fill><fillColor color="..."/></fill>`,边框常用 `<border color="..." width="2"/>`
|
||||
5. **插入位置**: 通过 `before_slide_id` 指定插入目标,而不是用 `position`
|
||||
6. **JSON 转义**: 如果直接内联 XML,需要正确转义双引号
|
||||
7. **本地预检**: 创建前运行 `layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
|
||||
7. **执行层预检**: 检查 XML well-formed、特殊字符转义、`slide.content` 包装和 `before_slide_id` 位置;布局质量检查不属于本执行层
|
||||
8. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
|
||||
|
||||
## 批量添加建议
|
||||
|
||||
Reference in New Issue
Block a user