Compare commits

...

7 Commits

Author SHA1 Message Date
luozhixiong
b0269d67ed test: cover interactive set-app-secret flow via injectable prompter 2026-06-26 18:38:19 +08:00
luozhixiong
dd0e1e9922 feat: add interactive prompt to config set-app-secret for terminal use 2026-06-26 17:52:40 +08:00
luozhixiong
2318e1cf81 fix: align set-app-secret confirmation hint/action with framework convention 2026-06-26 17:52:40 +08:00
luozhixiong
08fea3a85b docs: trim set-app-secret --help to drop info already in the flags table 2026-06-26 17:52:40 +08:00
luozhixiong
9ce91ea89a test: assert typed metadata, cause preservation, and clean stderr in set-app-secret tests 2026-06-26 17:52:40 +08:00
luozhixiong
2fc91d0579 feat: add config set-app-secret to rotate a profile's app secret
Add a config set-app-secret command so users and agents can rotate a
profile's app secret after resetting it on the open platform, instead of
hacking the OS keychain (which fails because the stored value lives in a
closed secure store). The command reads the new value from stdin, previews
the target and exits 10 without --yes, verifies the value via FetchTAT
before writing, stores it through core.ForStorage (the same primitive as
config init and config bind), and leaves other profiles untouched. It also
redirects the invalid_client hint to the new command and adds an optional
target field to the error envelope so the affected bot is named without
exposing the secret.
2026-06-26 17:52:40 +08:00
ethan-zhx
39d60cb706 feat: add slides replace-pages and xml-get shortcuts (#1585)
* feat: add slides replace-pages shortcut

* feat: add slides xml get shortcut

* fix: stop advertising slides screenshot scope

* feat: expose slides presentation url
2026-06-26 15:56:55 +08:00
24 changed files with 3021 additions and 41 deletions

View File

@@ -34,6 +34,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
cmd.AddCommand(NewCmdConfigSetAppSecret(f))
return cmd
}

View File

@@ -0,0 +1,459 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
// SetAppSecretOptions holds all inputs for config set-app-secret.
type SetAppSecretOptions struct {
Factory *cmdutil.Factory
AppSecretStdin bool
Yes bool
}
// NewCmdConfigSetAppSecret creates the config set-app-secret subcommand.
func NewCmdConfigSetAppSecret(f *cmdutil.Factory) *cobra.Command {
opts := &SetAppSecretOptions{Factory: f}
cmd := &cobra.Command{
Use: "set-app-secret",
Short: "Rotate a profile's stored app secret (verified before saving)",
Long: `Rotate a profile's app secret after you reset it on the Lark/Feishu open platform.
The new secret is verified against Lark before anything is saved; only the target
profile changes — other profiles and the active selection stay untouched.
Run it in a terminal with no flags for an interactive prompt (pick the profile,
confirm, then type the secret hidden). Pipe the secret with --app-secret-stdin
(and --yes to apply) for scripts and AI agents.
Targets the active profile; use the global --profile <name|app_id> to pick another.`,
RunE: func(cmd *cobra.Command, args []string) error {
return setAppSecretRun(f, opts)
},
}
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read the new secret from stdin (required with --yes).")
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Apply; without it, only preview the target and exit 10.")
// Do NOT call MarkFlagRequired("app-secret-stdin") — preview path (no --yes)
// must work without it; the "required" is enforced inside setAppSecretRun on
// the apply path only.
cmdutil.SetRisk(cmd, "high-risk-write")
return cmd
}
// setAppSecretRun dispatches between the interactive (human at a TTY) and the
// non-interactive (agent / piped / explicit-flag) paths. Both share the same
// verify-before-write and storage logic; only the way the target and the new
// secret are acquired differs.
func setAppSecretRun(f *cmdutil.Factory, opts *SetAppSecretOptions) error {
multi, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
profileOverride := f.Invocation.Profile
// Interactive mode: a human at a terminal who passed neither --app-secret-stdin
// nor --yes. Mirrors config bind's IsTUI gate. Any machine-facing flag, or a
// non-terminal stdout, takes the non-interactive path so agents/scripts keep
// the structured exit-10 + stdin protocol unchanged.
if f.IOStreams.IsTerminal && !opts.AppSecretStdin && !opts.Yes {
return setAppSecretInteractive(f, multi, profileOverride)
}
return setAppSecretNonInteractive(f, opts, multi, profileOverride)
}
// ── Non-interactive (agent / piped / explicit-flag) path ─────────────────────
// setAppSecretNonInteractive implements the structured protocol: confirm gate
// (no --yes → preview target + exit 10), require --app-secret-stdin, read the
// secret from stdin, verify, write, emit envelope.
func setAppSecretNonInteractive(f *cmdutil.Factory, opts *SetAppSecretOptions, multi *core.MultiAppConfig, profileOverride string) error {
app := multi.CurrentAppConfig(profileOverride)
if app == nil {
if profileOverride != "" {
return errs.NewConfigError(errs.SubtypeNotConfigured,
"profile %q not found", profileOverride).
WithHint("available profiles: %s", joinProfileNames(multi.ProfileNames()))
}
return core.NoActiveProfileError()
}
target := buildTarget(multi, app)
// Confirm gate — MUST happen before any stdin read.
if !opts.Yes {
// Follow the framework confirmation convention (internal/cmdutil/confirm.go):
// `action` is the operation identifier, and the hint tells the caller what to
// append to THEIR OWN invocation — never a pre-built "lark-cli …" string.
// A pre-built command hardcodes the binary name and trips shell-quoting /
// wrong-binary pitfalls (e.g. an older installed `lark-cli` without this
// subcommand). We add --profile <app_id> to the guidance so the apply pins
// the previewed target and cannot drift to a different active profile.
msg := fmt.Sprintf(
"app secret for profile %q (%s) will be rotated; confirm the target, then re-run with --profile %s --yes",
app.ProfileName(), app.AppId, app.AppId,
)
hint := fmt.Sprintf("add --profile %s --yes to confirm (pins the target shown above; pipe the new secret via stdin)", app.AppId)
return errs.NewConfirmationRequiredError(
errs.RiskHighRiskWrite,
"config set-app-secret",
"%s", msg,
).WithHint("%s", hint).WithTarget(target)
}
// Apply path: require --app-secret-stdin.
if !opts.AppSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
}
// Read and validate the new secret from stdin.
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if scanErr := scanner.Err(); scanErr != nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", scanErr).
WithCause(scanErr).
WithParam("--app-secret-stdin")
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
}
newSecret := strings.TrimSpace(scanner.Text())
if newSecret == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
}
// Verify, then write. verifyNewSecret returns typed errors (rendered as the
// structured envelope) carrying agent-facing retry hints.
if err := verifyNewSecret(f, app, target, newSecret); err != nil {
return err
}
migrated, err := storeNewSecret(multi, app, newSecret, f)
if err != nil {
return err
}
return emitSuccess(f, target, migrated)
}
// ── Interactive (human at a TTY) path ────────────────────────────────────────
// secretPrompter supplies the three interactive steps (profile pick, confirm,
// secret entry). The default implementation is backed by huh; tests inject fakes
// to drive runInteractive's orchestration without a real terminal.
type secretPrompter struct {
selectProfile func(multi *core.MultiAppConfig, profileOverride string) (*core.AppConfig, error)
confirm func(target *errs.ErrTarget) (bool, error)
readInput func() (string, error)
}
// defaultSecretPrompter wires the huh-backed interactive steps.
func defaultSecretPrompter() secretPrompter {
return secretPrompter{
selectProfile: selectTargetProfile,
confirm: confirmRotate,
readInput: promptHiddenInput,
}
}
// setAppSecretInteractive walks a human through profile selection, an explicit
// confirmation, and a hidden secret entry, then runs the same verify-before-write
// and storage logic.
func setAppSecretInteractive(f *cmdutil.Factory, multi *core.MultiAppConfig, profileOverride string) error {
return runInteractive(f, multi, profileOverride, defaultSecretPrompter())
}
// runInteractive orchestrates the interactive flow against the supplied prompter,
// then runs the same verify-before-write / storage logic as the agent path.
// Failures are rendered as readable lines (not the agent JSON envelope).
func runInteractive(f *cmdutil.Factory, multi *core.MultiAppConfig, profileOverride string, p secretPrompter) error {
app, err := p.selectProfile(multi, profileOverride)
if err != nil {
return err
}
target := buildTarget(multi, app)
confirmed, err := p.confirm(target)
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(f.IOStreams.ErrOut, "cancelled — nothing was changed")
return nil // explicit decline is a clean no-op (exit 0)
}
newSecret, err := p.readInput()
if err != nil {
return err
}
if err := verifyNewSecret(f, app, target, newSecret); err != nil {
// Render a readable line for humans instead of the agent JSON envelope.
code := output.ExitCodeOf(err)
humanMsg := err.Error()
if pr, ok := errs.ProblemOf(err); ok {
humanMsg = pr.Message
}
fmt.Fprintf(f.IOStreams.ErrOut, "✗ %s\n", humanMsg)
fmt.Fprintln(f.IOStreams.ErrOut, " run the command again to retry")
return output.ErrBare(code)
}
migrated, err := storeNewSecret(multi, app, newSecret, f)
if err != nil {
return err
}
return emitSuccess(f, target, migrated)
}
// selectTargetProfile resolves the profile to rotate. With --profile it uses
// that one; otherwise with a single profile it uses it; with several it shows
// a picker (active pre-selected).
func selectTargetProfile(multi *core.MultiAppConfig, profileOverride string) (*core.AppConfig, error) {
if profileOverride != "" {
app := multi.CurrentAppConfig(profileOverride)
if app == nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured,
"profile %q not found", profileOverride).
WithHint("available profiles: %s", joinProfileNames(multi.ProfileNames()))
}
return app, nil
}
if len(multi.Apps) == 0 {
return nil, core.NoActiveProfileError()
}
if len(multi.Apps) == 1 {
return &multi.Apps[0], nil
}
options, selected := profileSelectOptions(multi)
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Select the profile whose app secret to rotate").
Options(options...).
Value(&selected),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil, output.ErrBare(1)
}
return nil, err
}
return &multi.Apps[selected], nil
}
// profileSelectOptions builds the huh picker options for multiple profiles,
// labelling and pre-selecting the active one. Pure logic, unit-tested.
func profileSelectOptions(multi *core.MultiAppConfig) ([]huh.Option[int], int) {
activeApp := multi.CurrentAppConfig("")
options := make([]huh.Option[int], 0, len(multi.Apps))
selected := 0
for i := range multi.Apps {
a := &multi.Apps[i]
label := fmt.Sprintf("%s (%s)", a.ProfileName(), a.AppId)
if activeApp != nil && activeApp.ProfileName() == a.ProfileName() {
label += " [active]"
selected = i
}
options = append(options, huh.NewOption(label, i))
}
return options, selected
}
// confirmRotate shows a y/N confirmation for the resolved target.
func confirmRotate(target *errs.ErrTarget) (bool, error) {
activeSeg := ""
if target.IsActive {
activeSeg = " [active]"
}
confirmed := false
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Rotate the app secret for profile %q (%s)%s?", target.Profile, target.AppID, activeSeg)).
Description("The new secret is verified against Lark before anything is saved.").
Affirmative("Yes, rotate").
Negative("Cancel").
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return false, output.ErrBare(1)
}
return false, err
}
return confirmed, nil
}
// promptHiddenInput reads the new secret with a hidden input (matches
// config init's existing app-secret prompt style).
func promptHiddenInput() (string, error) {
var secret string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Enter the new app secret").
EchoMode(huh.EchoModePassword).
Validate(validateAppSecret).
Value(&secret),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return "", output.ErrBare(1)
}
return "", err
}
return strings.TrimSpace(secret), nil
}
// validateAppSecret is the huh input validator: the new secret must be non-empty.
// Pure logic, unit-tested.
func validateAppSecret(s string) error {
if strings.TrimSpace(s) == "" {
//nolint:forbidigo // huh inline form-validation message shown in the TUI, not a final CLI error
return fmt.Errorf("app secret must not be empty")
}
return nil
}
// ── Shared verify / write / output ───────────────────────────────────────────
// buildTarget assembles the structured target identity for the resolved profile.
// IsActive is true when the resolved profile equals the default active one.
func buildTarget(multi *core.MultiAppConfig, app *core.AppConfig) *errs.ErrTarget {
activeApp := multi.CurrentAppConfig("")
return &errs.ErrTarget{
Profile: app.ProfileName(),
AppID: app.AppId,
IsActive: activeApp != nil && activeApp.ProfileName() == app.ProfileName(),
}
}
// verifyNewSecret validates newSecret against Lark before any write, using the
// same FetchTAT + errs.IsTyped discriminator as cmd/config/init_probe.go. A typed
// error is a deterministic credential rejection (exit 3); an untyped error is
// transient/transport (exit 4). Neither path writes anything.
func verifyNewSecret(f *cmdutil.Factory, app *core.AppConfig, target *errs.ErrTarget, newSecret string) error {
verifyCtx, verifyCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer verifyCancel()
httpClient, err := f.HttpClient()
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "could not initialise HTTP client for secret verification, nothing was changed, please retry").
WithHint("the target is already confirmed — re-run with --profile %s --yes (no need to preview again)", app.AppId).
WithCause(err).
WithTarget(target)
}
if _, err := credential.FetchTAT(verifyCtx, httpClient, app.Brand, app.AppId, newSecret); err != nil {
if errs.IsTyped(err) {
// Deterministic credential rejection (invalid_client / unauthorized_client).
return errs.NewConfigError(errs.SubtypeInvalidClient, "new app secret is invalid, nothing was changed").
WithHint("the target is already confirmed — provide a valid secret and re-run with --profile %s --yes (no need to preview again)", app.AppId).
WithCause(err).
WithTarget(target)
}
// Transient / transport / timeout — surface as NetworkError so the caller
// knows to retry rather than treat it as a bad credential.
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "could not verify the new secret (transient error), nothing was changed, please retry").
WithHint("the target is already confirmed — re-run with --profile %s --yes (no need to preview again)", app.AppId).
WithCause(err).
WithTarget(target)
}
return nil
}
// storeNewSecret writes newSecret via core.ForStorage (the single canonical
// secret-storage primitive, identical to config init / config bind) and updates
// config.json only when migrating a plaintext/file source to a keychain ref.
// Returns migrated=true only in that migration case.
func storeNewSecret(multi *core.MultiAppConfig, app *core.AppConfig, newSecret string, f *cmdutil.Factory) (bool, error) {
stored, err := core.ForStorage(app.AppId, core.PlainSecret(newSecret), f.Keychain)
if err != nil {
return false, errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
}
// If the existing secret is already a keychain ref, ForStorage overwrote the
// keychain entry in-place — the ref is unchanged, so we must NOT rewrite
// config.json (byte-level stability). For plain/file sources, migrate the ref.
idx := multi.FindAppIndex(app.AppId)
orig := multi.Apps[idx].AppSecret
if orig.IsSecretRef() && orig.Ref.Source == "keychain" {
return false, nil
}
// plain/file source: update only this profile's AppSecret field; all other
// profiles and all other fields of this profile are left completely untouched.
multi.Apps[idx].AppSecret = stored
if err := core.SaveMultiAppConfig(multi); err != nil {
return false, errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
return true, nil
}
// emitSuccess writes the result: a JSON envelope on stdout for non-terminal
// (piped / agent / script) callers, a pretty line for a human terminal.
func emitSuccess(f *cmdutil.Factory, target *errs.ErrTarget, migrated bool) error {
if !f.IOStreams.IsTerminal {
envelope := map[string]any{
"ok": true,
"identity": "bot",
"data": map[string]any{
"profile": target.Profile,
"app_id": target.AppID,
"is_active": target.IsActive,
"verified": true,
"migrated": migrated,
},
}
resultJSON, _ := json.Marshal(envelope)
fmt.Fprintln(f.IOStreams.Out, string(resultJSON))
return nil
}
// Pretty line: ✓ app secret updated for profile "cursor" (cli_xxxxx) [active] — verified
activeSegment := ""
if target.IsActive {
activeSegment = " [active]"
}
fmt.Fprintf(f.IOStreams.Out,
"✓ app secret updated for profile %q (%s)%s — verified\n",
target.Profile, target.AppID, activeSegment,
)
return nil
}
// joinProfileNames joins profile names for a hint message.
func joinProfileNames(names []string) string {
if len(names) == 0 {
return "(none)"
}
result := ""
for i, n := range names {
if i > 0 {
result += ", "
}
result += n
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,16 @@
package errs
// ErrTarget carries the structured identity of the bot/profile that a
// confirmation-required or config-change operation will affect. It is
// intentionally limited to non-secret fields: profile name, app_id, and
// whether the profile is the active one. Secret values must never appear here.
type ErrTarget struct {
Profile string `json:"profile,omitempty"`
AppID string `json:"app_id,omitempty"`
IsActive bool `json:"is_active"`
}
// Problem is the RFC 7807-aligned shared shape embedded by every typed error.
//
// Message is REQUIRED. Producers must populate it; an empty Message will make
@@ -20,15 +30,19 @@ package errs
// includes it, otherwise absent.
// - Retryable uses omitempty so only `true` is emitted; consumers treat
// absence as false.
// - Target is optional; when present it identifies the bot/profile that the
// operation will affect so an AI agent or user can confirm the right target
// before a secret rotation or config change proceeds.
type Problem struct {
Category Category `json:"type"`
Subtype Subtype `json:"subtype,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
LogID string `json:"log_id,omitempty"`
Troubleshooter string `json:"troubleshooter,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Category Category `json:"type"`
Subtype Subtype `json:"subtype,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
LogID string `json:"log_id,omitempty"`
Troubleshooter string `json:"troubleshooter,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Target *ErrTarget `json:"target,omitempty"`
}
// Error satisfies the standard `error` interface. A nil receiver is treated

View File

@@ -4,7 +4,9 @@
package errs
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
@@ -70,3 +72,13 @@ func TestProblemCategoryTagIsType(t *testing.T) {
t.Errorf("Problem.Category json tag = %q, want %q", got, "type")
}
}
func TestProblem_TargetSerialized(t *testing.T) {
e := NewConfirmationRequiredError(RiskHighRiskWrite, "x", "msg").
WithTarget(&ErrTarget{Profile: "cursor", AppID: "cli_x", IsActive: true})
b, _ := json.Marshal(e)
s := string(b)
if !strings.Contains(s, `"target"`) || !strings.Contains(s, `"app_id":"cli_x"`) || !strings.Contains(s, `"is_active":true`) {
t.Fatalf("target not serialized: %s", s)
}
}

View File

@@ -150,6 +150,11 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
return e
}
func (e *ValidationError) WithTarget(t *ErrTarget) *ValidationError {
e.Target = t
return e
}
// =========================== AuthenticationError =============================
// AuthenticationError is the typed error for CategoryAuthentication.
@@ -375,6 +380,11 @@ func (e *ConfigError) WithCause(cause error) *ConfigError {
return e
}
func (e *ConfigError) WithTarget(t *ErrTarget) *ConfigError {
e.Target = t
return e
}
// =============================== NetworkError ================================
// NetworkError is the typed error for CategoryNetwork. The Subtype carries
@@ -437,6 +447,11 @@ func (e *NetworkError) WithCause(cause error) *NetworkError {
return e
}
func (e *NetworkError) WithTarget(t *ErrTarget) *NetworkError {
e.Target = t
return e
}
// ================================ APIError ===================================
// APIError is the typed error for CategoryAPI (catch-all for classified Lark
@@ -772,3 +787,8 @@ func (e *ConfirmationRequiredError) WithCause(cause error) *ConfirmationRequired
e.Cause = cause
return e
}
func (e *ConfirmationRequiredError) WithTarget(t *ErrTarget) *ConfirmationRequiredError {
e.Target = t
return e
}

View File

@@ -243,7 +243,7 @@ func buildConfigError(p errs.Problem) *errs.ConfigError {
func ConfigHint(subtype errs.Subtype) string {
switch subtype {
case errs.SubtypeInvalidClient:
return "run `lark-cli config init` to set valid app_id and app_secret"
return "app secret invalid — rotate it: lark-cli config set-app-secret --app-secret-stdin (provide the new secret via stdin; previews the target first, then re-run with --profile <app_id> --yes to apply)"
case errs.SubtypeNotConfigured:
return "run `lark-cli config init` to set up app_id and app_secret"
case errs.SubtypeInvalidConfig:

View File

@@ -113,7 +113,8 @@ func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) {
// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a
// CategoryConfig response (Lark code 10014 — "app secret invalid") flows
// through BuildAPIError, the resulting *ConfigError MUST carry the canonical
// recovery hint pointing the user at `lark-cli config init`.
// recovery hint pointing the user at `lark-cli config set-app-secret --app-secret-stdin`
// (secret rotation, not profile re-init).
func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) {
const code = 10014
resp := map[string]any{"code": code, "msg": "app secret invalid"}
@@ -130,7 +131,10 @@ func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) {
if cfgErr.Hint == "" {
t.Errorf("Hint is empty; canonical hint required for invalid_client")
}
if !strings.Contains(cfgErr.Hint, "lark-cli config init") {
t.Errorf("Hint should reference `lark-cli config init`; got %q", cfgErr.Hint)
if !strings.Contains(cfgErr.Hint, "config set-app-secret --app-secret-stdin") {
t.Errorf("Hint should reference `config set-app-secret --app-secret-stdin`; got %q", cfgErr.Hint)
}
if strings.Contains(cfgErr.Hint, "config init") {
t.Errorf("Hint must not point to config init; got %q", cfgErr.Hint)
}
}

View File

@@ -930,6 +930,46 @@ func TestPermissionError_HintPopulated(t *testing.T) {
}
}
// TestConfigHint_InvalidClientPointsToRotate pins that the SubtypeInvalidClient
// hint redirects to `config set-app-secret --app-secret-stdin` (secret rotation)
// and NOT to `config init` (which overwrites the whole profile). Both Lark codes
// 99991543 and 10014 map to SubtypeInvalidClient (codemeta.go:67-68), so this
// one hint change covers both codes.
func TestConfigHint_InvalidClientPointsToRotate(t *testing.T) {
h := errclass.ConfigHint(errs.SubtypeInvalidClient)
// Entry segment must be present (no --yes — just previews).
if !strings.Contains(h, "config set-app-secret --app-secret-stdin (") {
t.Fatalf("hint=%q: must contain entry segment 'config set-app-secret --app-secret-stdin ('", h)
}
// Apply step must carry --yes so an agent can actually apply.
if !strings.Contains(h, "--profile <app_id> --yes to apply") {
t.Fatalf("hint=%q: must contain '--profile <app_id> --yes to apply'", h)
}
if strings.Contains(h, "config init") {
t.Fatal("hint must not point to config init")
}
// Optional: assert that both Lark codes that mean "app secret invalid"
// route through this hint (codemeta.go maps them to SubtypeInvalidClient).
for _, code := range []int{99991543, 10014} {
resp := map[string]any{"code": code, "msg": "app secret invalid"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
if err == nil {
t.Fatalf("code %d: expected error, got nil", code)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("code %d: ProblemOf returned !ok", code)
}
if p.Subtype != errs.SubtypeInvalidClient {
t.Errorf("code %d: Subtype = %q, want SubtypeInvalidClient", code, p.Subtype)
}
if !strings.Contains(p.Hint, "config set-app-secret --app-secret-stdin") {
t.Errorf("code %d: Hint = %q: must contain 'config set-app-secret --app-secret-stdin'", code, p.Hint)
}
}
}
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
// SDK parses with json.Number; verify intFromAny handles it.
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}

View File

@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
}
}

View File

@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
// Prefer the URL returned by presentation.create. Fall back to a local
// brand-standard URL only when the API omits it.
if url := common.GetString(data, "url"); url != "" {
result["url"] = url
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}

View File

@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_abc123",
"revision_id": 1,
"url": "https://tenant.example.com/slides/pres_abc123",
},
},
})
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
// URL is built locally from the token (brand-standard host), not fetched from
// drive metas, so it is deterministic and needs no drive scope.
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
}
}
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
// locally from the token — no drive metas/batch_query call is made, so creation
// works for users who only authorized slides scopes. The httpmock registry has no
// batch_query stub registered; if the shortcut tried to call it, the request would
// fail the test (unregistered stub), proving the URL is built without a drive call.
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
// constructed locally from the token when presentation.create omits url — no
// drive metas/batch_query call is made, so creation works for users who only
// authorized slides scopes. The httpmock registry has no batch_query stub
// registered; if the shortcut tried to call it, the request would fail the test.
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
"data": map[string]interface{}{
"xml_presentation_id": "pres_local_url",
"revision_id": 1,
"url": "",
},
},
})

View File

@@ -0,0 +1,426 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
// It deliberately creates the new page before deleting the old one so a create
// failure cannot remove existing user content. The operation is not atomic.
const replacePagesInitialRevisionID = -1
var SlidesReplacePages = common.Shortcut{
Service: "slides",
Command: "+replace-pages",
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return err
}
return validateReplacePagesInput(pages)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI()
resolved, err := prepareReplacePages(runtime)
if err != nil {
return dry.Set("error", err.Error())
}
appendReplacePagesDryRunCalls(dry, resolved)
return dry.
Set("xml_presentation_id", resolved.PresentationID).
Set("pages_count", len(resolved.Plan)).
Set("plan", replacePagesPlanOutput(resolved.Plan)).
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
resolved, err := prepareReplacePages(runtime)
if err != nil {
return err
}
if runtime.Bool("validate-only") {
runtime.Out(map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"plan": replacePagesPlanOutput(resolved.Plan),
"status": "validated",
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
}, nil)
return nil
}
revisionID := replacePagesInitialRevisionID
results := make([]replacePageResult, 0, len(resolved.Plan))
for i, item := range resolved.Plan {
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
results = append(results, result)
if result.RevisionID != nil {
revisionID = *result.RevisionID
}
if err != nil {
if runtime.Bool("continue-on-error") {
continue
}
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
}
}
out := map[string]interface{}{
"xml_presentation_id": resolved.PresentationID,
"pages_count": len(resolved.Plan),
"results": replacePageResultsOutput(results),
"status": "completed",
"summary": replacePagesSummaryOutput(results),
"note": "batch replace is not atomic; each page was created before its old page was deleted",
}
if revisionID != replacePagesInitialRevisionID {
out["revision_id"] = revisionID
}
if hasReplacePageFailures(results) {
out["status"] = "partial_failure"
return runtime.OutPartialFailure(out, nil)
}
runtime.Out(out, nil)
return nil
},
}
type replacePageInput struct {
SlideID string
Content string
}
type replacePagePlanItem struct {
OldSlideID string
Content string
Locator string
}
type replacePagesPrepared struct {
PresentationID string
Plan []replacePagePlanItem
}
type replacePageResult struct {
OldSlideID string
NewSlideID string
Status string
Error string
RevisionID *int
}
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return nil, err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return nil, err
}
pages, err := parseReplacePages(runtime.Str("pages"))
if err != nil {
return nil, err
}
if err := validateReplacePagesInput(pages); err != nil {
return nil, err
}
plan, err := buildReplacePagesPlan(pages)
if err != nil {
return nil, err
}
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
}
func parseReplacePages(raw string) ([]replacePageInput, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
}
var decoded []map[string]interface{}
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
}
out := make([]replacePageInput, 0, len(decoded))
for i, m := range decoded {
p := replacePageInput{}
if v, ok := m["slide_number"]; ok {
_ = v
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
}
if v, ok := m["slide_id"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
}
p.SlideID = s
}
if v, ok := m["content"]; ok {
s, ok := v.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
}
p.Content = s
}
out = append(out, p)
}
return out, nil
}
func validateReplacePagesInput(pages []replacePageInput) error {
if len(pages) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
}
seenIDs := map[string]bool{}
for i, p := range pages {
id := strings.TrimSpace(p.SlideID)
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
}
if seenIDs[id] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
}
seenIDs[id] = true
if strings.TrimSpace(p.Content) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
}
if err := validateCompleteSlideXML(p.Content); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
}
}
return nil
}
func validateCompleteSlideXML(content string) error {
dec := xml.NewDecoder(strings.NewReader(content))
depth := 0
seenRoot := false
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
switch t := tok.(type) {
case xml.StartElement:
if depth == 0 {
if seenRoot {
return invalidSlideXMLStructureError("multiple root elements")
}
if t.Name.Local != "slide" {
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
}
seenRoot = true
}
depth++
case xml.EndElement:
depth--
case xml.CharData:
if depth == 0 && strings.TrimSpace(string(t)) != "" {
return invalidSlideXMLStructureError("non-whitespace text outside root element")
}
}
}
if !seenRoot {
return invalidSlideXMLStructureError("missing root element")
}
if depth != 0 {
return invalidSlideXMLStructureError("unclosed XML element")
}
return nil
}
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
plan := make([]replacePagePlanItem, 0, len(pages))
for _, page := range pages {
id := strings.TrimSpace(page.SlideID)
plan = append(plan, replacePagePlanItem{
OldSlideID: id,
Content: page.Content,
Locator: "slide_id",
})
}
return plan, nil
}
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
for i, item := range resolved.Plan {
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
})
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
Params(map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": "<revision_returned_by_create>",
})
}
}
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
result := replacePageResult{
OldSlideID: item.OldSlideID,
Status: "pending",
}
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
createData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": revisionID},
map[string]interface{}{
"slide": map[string]interface{}{"content": item.Content},
"before_slide_id": item.OldSlideID,
},
)
if err != nil {
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
newSlideID := common.GetString(createData, "slide_id")
if newSlideID == "" {
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
result.Status = "create_failed"
result.Error = err.Error()
return result, err
}
result.NewSlideID = newSlideID
if rev, ok := revisionFromData(createData); ok {
revisionID = rev
result.RevisionID = &rev
}
deleteData, err := runtime.CallAPITyped(
"DELETE",
slideURL,
map[string]interface{}{
"slide_id": item.OldSlideID,
"revision_id": revisionID,
},
nil,
)
if err != nil {
result.Status = "delete_failed"
result.Error = err.Error()
return result, err
}
if rev, ok := revisionFromData(deleteData); ok {
result.RevisionID = &rev
}
result.Status = "replaced"
return result, nil
}
func revisionFromData(data map[string]interface{}) (int, bool) {
if _, ok := data["revision_id"]; !ok {
return 0, false
}
return int(common.GetFloat(data, "revision_id")), true
}
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(plan))
for _, item := range plan {
out = append(out, map[string]interface{}{
"old_slide_id": item.OldSlideID,
"insert_before_slide_id": item.OldSlideID,
"locator": item.Locator,
"action": "create_before_then_delete_old",
})
}
return out
}
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(results))
for _, result := range results {
m := map[string]interface{}{
"old_slide_id": result.OldSlideID,
"status": result.Status,
}
if result.NewSlideID != "" {
m["new_slide_id"] = result.NewSlideID
}
if result.Error != "" {
m["error"] = result.Error
}
if result.RevisionID != nil {
m["revision_id"] = *result.RevisionID
}
out = append(out, m)
}
return out
}
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
replaced := countReplacedPages(results)
return map[string]interface{}{
"replaced": replaced,
"failed": len(results) - replaced,
"total": len(results),
}
}
func countReplacedPages(results []replacePageResult) int {
n := 0
for _, result := range results {
if result.Status == "replaced" {
n++
}
}
return n
}
func hasReplacePageFailures(results []replacePageResult) bool {
for _, result := range results {
if result.Status == "create_failed" || result.Status == "delete_failed" {
return true
}
}
return false
}

View File

@@ -0,0 +1,341 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestReplacePagesDeclaredScopes(t *testing.T) {
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
}
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
}
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
var requestOrder []string
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
},
}
reg.Register(createStub)
var deleteQuery map[string][]string
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
OnMatch: func(req *http.Request) {
requestOrder = append(requestOrder, req.Method)
deleteQuery = req.URL.Query()
},
}
reg.Register(deleteStub)
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var createBody struct {
Slide struct {
Content string `json:"content"`
} `json:"slide"`
BeforeSlideID string `json:"before_slide_id"`
}
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
}
if createBody.BeforeSlideID != "old2" {
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
}
if !strings.Contains(createBody.Slide.Content, "<slide") {
t.Fatalf("create content = %q", createBody.Slide.Content)
}
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
}
deleteURL := string(deleteStub.CapturedBody)
if deleteURL != "" {
t.Fatalf("delete body = %q, want empty", deleteURL)
}
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
t.Fatalf("delete slide_id = %#v, want old2", got)
}
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
if data["revision_id"] != float64(12) {
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["failed"] != float64(0) {
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
}
results, _ := data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
t.Fatalf("result = %#v", first)
}
}
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"revision_id": 12},
},
})
pages := `[
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
data := env.Data
if data["status"] != "partial_failure" {
t.Fatalf("status = %v, want partial_failure", data["status"])
}
summary, _ := data["summary"].(map[string]interface{})
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
}
results, _ := data["results"].([]interface{})
if len(results) != 2 {
t.Fatalf("results len = %d, want 2", len(results))
}
first, _ := results[0].(map[string]interface{})
second, _ := results[1].(map[string]interface{})
if first["status"] != "create_failed" {
t.Fatalf("first status = %v, want create_failed", first["status"])
}
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
t.Fatalf("second result = %#v, want replaced with new2", second)
}
}
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
Body: map[string]interface{}{
"code": 3350001,
"msg": "invalid param",
"data": map[string]interface{}{},
},
})
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--continue-on-error",
"--as", "user",
})
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
}
env := decodeReplacePagesEnvelope(t, stdout)
if env.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
results, _ := env.Data["results"].([]interface{})
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
first, _ := results[0].(map[string]interface{})
if first["status"] != "delete_failed" {
t.Fatalf("status = %v, want delete_failed", first["status"])
}
if first["new_slide_id"] != "new1" {
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
}
}
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", pages,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
}
if out["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
}
plan, _ := out["plan"].([]interface{})
if len(plan) != 1 {
t.Fatalf("plan len = %d, want 1", len(plan))
}
item, _ := plan[0].(map[string]interface{})
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
t.Fatalf("plan item = %#v", item)
}
api, _ := out["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("api len = %d, want create/delete plan", len(api))
}
}
func TestReplacePagesValidationParam(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pages string
}{
{"empty pages", `[]`},
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
{"no locator", `[{"content":"<slide/>"}]`},
{"empty content", `[{"slide_id":"s1","content":" "}]`},
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
"+replace-pages",
"--presentation", "pres_abc",
"--pages", tt.pages,
"--as", "user",
})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %v, want *errs.ValidationError", err)
}
if ve.Param != "--pages" {
t.Fatalf("Param = %q, want --pages", ve.Param)
}
})
}
}
type replacePagesEnvelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
t.Helper()
var env replacePagesEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
}
if env.Data == nil {
t.Fatalf("missing data: %#v", env)
}
return env
}

View File

@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
Command: "+replace-slide",
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
Risk: "write",
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("slide-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
@@ -15,6 +16,21 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)
func TestReplaceSlideDeclaredScopes(t *testing.T) {
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
}
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
}
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
// onto the root before sending. The backend returns 3350001 otherwise.

View File

@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},

View File

@@ -17,11 +17,23 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesXMLGet fetches the full XML presentation content and writes it to a
// local file, keeping the terminal output small for large decks.
var SlidesXMLGet = common.Shortcut{
Service: "slides",
Command: "+xml-get",
Description: "Fetch full presentation XML and save it to a local file",
Risk: "read",
Scopes: []string{"slides:presentation:read"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if strings.TrimSpace(runtime.Str("output")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
}
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
}
if runtime.Int("revision-id") < -1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc("Fetch full presentation XML and save it to a local file")
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
dry.GET(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s",
validate.EncodePathSegment(presentationID),
)).
Params(params)
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
}
if runtime.Bool("remove-attr-id") {
params["remove_attr_id"] = true
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
params,
nil,
)
if err != nil {
return err
}
presentation := common.GetMap(data, "xml_presentation")
content := common.GetString(presentation, "content")
if content == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
}
outputPath := runtime.Str("output")
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),
}, bytes.NewReader([]byte(content)))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(outputPath)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
}
out := map[string]interface{}{
"xml_presentation_id": presentationID,
"path": resolvedPath,
"size": result.Size(),
"content_saved": true,
}
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
out["revision_id"] = int(revisionID)
}
if runtime.Bool("remove-attr-id") {
out["remove_attr_id"] = true
}
runtime.Out(out, nil)
return nil
},
}

View File

@@ -0,0 +1,165 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
var capturedQuery url.Values
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"presentation_id": "pres_abc",
"revision_id": 7,
"content": xml,
},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "readback.xml",
"--revision-id", "7",
"--remove-attr-id",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "readback.xml")
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved XML: %v", err)
}
if string(got) != xml {
t.Fatalf("saved XML = %q, want %q", got, xml)
}
if strings.Contains(stdout.String(), xml) {
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
}
if got := capturedQuery.Get("revision_id"); got != "7" {
t.Fatalf("revision_id query = %q, want 7", got)
}
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
t.Fatalf("remove_attr_id query = %q, want true", got)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
}
if data["revision_id"] != float64(7) {
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
}
if data["size"] != float64(len(xml)) {
t.Fatalf("size = %v, want %d", data["size"], len(xml))
}
gotPath, _ := data["path"].(string)
if !filepath.IsAbs(gotPath) {
t.Fatalf("path = %v, want absolute path", gotPath)
}
if !strings.HasSuffix(gotPath, "readback.xml") {
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "slides",
"obj_token": "pres_real",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": `<presentation/>`,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
"--output", "wiki.xml",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_real" {
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
}
}
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--output", "../readback.xml",
"--as", "user",
})
if err == nil {
t.Fatal("expected unsafe output path error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "--output" {
t.Fatalf("param = %q, want --output", validationErr.Param)
}
}

View File

@@ -15,7 +15,7 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get``lark-slides-replace-pages.md``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot``lark-slides-screenshot.md` |
@@ -47,7 +47,7 @@ metadata:
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
**编辑已有幻灯片页面**单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
## 身份选择
@@ -82,7 +82,7 @@ lark-cli auth login --domain slides
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
@@ -268,6 +268,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+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/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
@@ -286,7 +287,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
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`
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
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 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -1,6 +1,6 @@
# 编辑已有 PPT读-改-写闭环
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
@@ -11,6 +11,7 @@
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement``id` 由 CLI 自动注入为 `block_id` |
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace``block_insert` 可混用 |
| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old不生成新 Slides 链接 |
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
@@ -136,6 +137,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
## 相关文档
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token

View File

@@ -0,0 +1,95 @@
# slides +replace-pages多页整页重建
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
> 重要这是多步编排不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
## 命令
```bash
lark-cli slides +replace-pages \
--as user \
--presentation <slides_url_or_xml_presentation_id> \
--pages @pages.json
```
## 参数
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id``/slides/` URL 或 `/wiki/` URL |
| `--pages` | 是 | JSON 数组,每项包含 `slide_id``content`;支持 literal、`@file`、stdin `-` |
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
## pages.json
```json
[
{
"slide_id": "slide_short_id_1",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
},
{
"slide_id": "slide_short_id_2",
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
}
]
```
规则:
- 每项必须提供 `slide_id`;不支持 `slide_number`
- `content` 必须是完整 `<slide>...</slide>` XML。
- 同一批次不能重复 `slide_id`
- CLI 不会回读整份 presentation如果 `slide_id` 已失效create/delete 阶段会返回对应错误。
## Dry Run
```bash
lark-cli slides +replace-pages --as user \
--presentation "$PID" \
--pages @pages.json \
--dry-run
```
输出包含 `xml_presentation_id``pages_count``plan`,以及每页的 `old_slide_id``insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
## 成功输出
```json
{
"xml_presentation_id": "xxx",
"pages_count": 2,
"status": "completed",
"summary": {
"replaced": 2,
"failed": 0,
"total": 2
},
"results": [
{
"old_slide_id": "old3",
"new_slide_id": "new3",
"status": "replaced"
}
],
"revision_id": 123
}
```
如果使用 `--continue-on-error` 且任一页面失败CLI 会继续处理后续页,但最终以 partial failure 非零退出stdout 仍保留完整 `results`,顶层 `ok``false``status``partial_failure`
`status` 可能为:
- `replaced`:新页创建成功,旧页删除成功。
- `create_failed`:新页创建失败,旧页保留。
- `delete_failed`:新页已创建,但旧页删除失败。
## 使用建议
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML并记录要替换页面的 `slide_id`
2. 生成只含 `slide_id``pages.json` 后先跑 `--dry-run``--validate-only`
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径
## 命令