diff --git a/README.md b/README.md index 6107ff0d7..5f554525a 100644 --- a/README.md +++ b/README.md @@ -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, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 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, and more, with 200+ commands and 21 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** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup -- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/) +- **Agent-Native Design** — 21 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup +- **Wide Coverage** — 13 business domains, 200+ curated commands, 21 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 @@ -30,6 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t | 📁 Drive | Upload and download files, search docs & wiki, manage comments | | 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics | | 📈 Sheets | Create, read, write, append, find, and export spreadsheet data | +| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides | | ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders | | 📚 Wiki | Create and manage knowledge spaces, nodes, and documents | | 👤 Contact | Search users by name/email/phone, get user profiles | @@ -136,6 +137,7 @@ lark-cli auth status | `lark-doc` | Create, read, update, search documents (Markdown-based) | | `lark-drive` | Upload, download files, manage permissions & comments | | `lark-sheets` | Create, read, write, append, find, export spreadsheets | +| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides | | `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 | diff --git a/README.zh.md b/README.zh.md index 4d5261869..6a0c8246a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,14 +6,14 @@ [中文版](./README.zh.md) | [English](./README.md) -飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。 +飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 21 个 AI Agent [Skills](./skills/)。 [安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) ## 为什么选 lark-cli? -- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 -- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/) +- **为 Agent 原生设计** — 21 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 +- **覆盖面广** — 13 大业务域、200+ 精选命令、21 个 AI Agent [Skills](./skills/) - **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率 - **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用 - **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步 @@ -30,6 +30,7 @@ | 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 | | 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 | | 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 | +| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | | ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 | | 📚 知识库 | 创建和管理知识空间、节点和文档 | | 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 | @@ -137,6 +138,7 @@ lark-cli auth status | `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) | | `lark-drive` | 上传、下载文件,管理权限与评论 | | `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 | +| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | | `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 | | `lark-task` | 任务、任务清单、子任务、提醒、成员分配 | | `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 | diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index f12e8d993..4c0eefc79 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -43,6 +43,10 @@ "en": { "title": "Sheets", "description": "Spreadsheet operations" }, "zh": { "title": "电子表格", "description": "电子表格操作" } }, + "slides": { + "en": { "title": "Slides", "description": "Create and manage presentations, read content, and add or remove slides" }, + "zh": { "title": "幻灯片", "description": "创建和管理演示文稿、读取内容,以及新增或删除幻灯片页面" } + }, "task": { "en": { "title": "Task", "description": "Task, task list, and subtask management" }, "zh": { "title": "任务", "description": "任务、清单、子任务管理" } diff --git a/shortcuts/common/permission_grant.go b/shortcuts/common/permission_grant.go index 68be2f469..173c8143a 100644 --- a/shortcuts/common/permission_grant.go +++ b/shortcuts/common/permission_grant.go @@ -120,6 +120,8 @@ func permissionTargetLabel(resourceType string) string { return "spreadsheet" case "bitable", "base": return "base" + case "slides": + return "presentation" case "file": return "file" case "folder": diff --git a/shortcuts/register.go b/shortcuts/register.go index 79b088149..09d3813b9 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/shortcuts/mail" "github.com/larksuite/cli/shortcuts/minutes" "github.com/larksuite/cli/shortcuts/sheets" + "github.com/larksuite/cli/shortcuts/slides" "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" "github.com/larksuite/cli/shortcuts/whiteboard" @@ -38,6 +39,7 @@ func init() { allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) + allShortcuts = append(allShortcuts, slides.Shortcuts()...) allShortcuts = append(allShortcuts, minutes.Shortcuts()...) allShortcuts = append(allShortcuts, task.Shortcuts()...) allShortcuts = append(allShortcuts, vc.Shortcuts()...) diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go new file mode 100644 index 000000000..19ad2449d --- /dev/null +++ b/shortcuts/slides/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all slides shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + SlidesCreate, + } +} diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go new file mode 100644 index 000000000..3db5805b3 --- /dev/null +++ b/shortcuts/slides/slides_create.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultPresentationWidth = 960 + defaultPresentationHeight = 540 + maxSlidesPerCreate = 10 +) + +// SlidesCreate creates a new Lark Slides presentation with bot auto-grant. +var SlidesCreate = common.Shortcut{ + Service: "slides", + Command: "+create", + Description: "Create a Lark Slides presentation", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"}, + Flags: []common.Flag{ + {Name: "title", Desc: "presentation title"}, + {Name: "slides", Desc: "slide content JSON array (each element is a XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if slidesStr := runtime.Str("slides"); slidesStr != "" { + var slides []string + if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil { + return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings") + } + if len(slides) > maxSlidesPerCreate { + return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + title := effectiveTitle(runtime.Str("title")) + slidesStr := runtime.Str("slides") + createBody := map[string]interface{}{ + "xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)}, + } + + dry := common.NewDryRunAPI() + + if slidesStr == "" { + dry.Desc("Create empty presentation"). + POST("/open-apis/slides_ai/v1/xml_presentations"). + Body(createBody) + } else { + var slides []string + _ = json.Unmarshal([]byte(slidesStr), &slides) + n := len(slides) + total := n + 1 + + dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)). + POST("/open-apis/slides_ai/v1/xml_presentations"). + Desc(fmt.Sprintf("[1/%d] Create presentation", total)). + Body(createBody) + + for i, slideXML := range slides { + dry.POST("/open-apis/slides_ai/v1/xml_presentations//slide"). + Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)). + Body(map[string]interface{}{ + "slide": map[string]interface{}{"content": slideXML}, + }) + } + } + + if runtime.IsBot() { + dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.") + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + title := effectiveTitle(runtime.Str("title")) + content := buildPresentationXML(title) + slidesStr := runtime.Str("slides") + + // Step 1: Create presentation + data, err := runtime.CallAPI( + "POST", + "/open-apis/slides_ai/v1/xml_presentations", + nil, + map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "content": content, + }, + }, + ) + if err != nil { + return err + } + + presentationID := common.GetString(data, "xml_presentation_id") + if presentationID == "" { + return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id") + } + + result := map[string]interface{}{ + "xml_presentation_id": presentationID, + "title": title, + } + if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 { + result["revision_id"] = int(revisionID) + } + + // Step 2: Add slides if provided + if slidesStr != "" { + var slides []string + _ = json.Unmarshal([]byte(slidesStr), &slides) // already validated + + if len(slides) > 0 { + slideURL := fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s/slide", + validate.EncodePathSegment(presentationID), + ) + + var slideIDs []string + for i, slideXML := range slides { + slideData, err := runtime.CallAPI( + "POST", + slideURL, + map[string]interface{}{"revision_id": -1}, + map[string]interface{}{ + "slide": map[string]interface{}{"content": slideXML}, + }, + ) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", + "slide %d/%d failed: %v (presentation %s was created; %d slide(s) added before failure)", + i+1, len(slides), err, presentationID, i) + } + if sid := common.GetString(slideData, "slide_id"); sid != "" { + slideIDs = append(slideIDs, sid) + } + } + + result["slide_ids"] = slideIDs + result["slides_added"] = len(slideIDs) + } + } + + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil { + result["permission_grant"] = grant + } + + runtime.Out(result, nil) + return nil + }, +} + +// effectiveTitle returns the title to use, falling back to "Untitled". +func effectiveTitle(title string) string { + if title == "" { + return "Untitled" + } + return title +} + +// buildPresentationXML builds the minimal XML for a new empty presentation. +func buildPresentationXML(title string) string { + escapedTitle := xmlEscape(title) + if escapedTitle == "" { + escapedTitle = "Untitled" + } + return fmt.Sprintf( + `%s`, + defaultPresentationWidth, defaultPresentationHeight, escapedTitle, + ) +} + +// xmlEscape escapes special XML characters in text content. +func xmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go new file mode 100644 index 000000000..6c4affe2f --- /dev/null +++ b/shortcuts/slides/slides_create_test.go @@ -0,0 +1,582 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestSlidesCreateBasic verifies that slides +create returns the presentation ID and title in user mode. +func TestSlidesCreateBasic(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_abc123", + "revision_id": 1, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目汇报", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + if data["xml_presentation_id"] != "pres_abc123" { + t.Fatalf("xml_presentation_id = %v, want pres_abc123", data["xml_presentation_id"]) + } + if data["title"] != "项目汇报" { + t.Fatalf("title = %v, want 项目汇报", data["title"]) + } + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode") + } +} + +// TestSlidesCreateBotAutoGrant verifies that bot mode grants the current user full_access on the new presentation. +func TestSlidesCreateBotAutoGrant(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_bot", + "revision_id": 1, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/pres_bot/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ou_current_user", + "member_type": "openid", + "perm": "full_access", + }, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Bot PPT", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantGranted) + } + if !strings.Contains(grant["message"].(string), "presentation") { + t.Fatalf("permission_grant.message = %q, want 'presentation' mention", grant["message"]) + } +} + +// TestSlidesCreateBotSkippedWithoutCurrentUser verifies that permission grant is skipped when no user open_id is configured. +func TestSlidesCreateBotSkippedWithoutCurrentUser(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_no_user", + "revision_id": 1, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "No User PPT", + "--as", "bot", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantSkipped { + t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped) + } +} + +// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled". +func TestSlidesCreateDryRunDefaultTitle(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Untitled") { + t.Fatalf("dry-run should contain Untitled in XML payload, got: %s", out) + } + if !strings.Contains(out, "xml_presentations") { + t.Fatalf("dry-run should show API path, got: %s", out) + } +} + +// TestSlidesCreateDefaultTitle verifies that omitting --title outputs "Untitled" (matching the actual resource). +func TestSlidesCreateDefaultTitle(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_default", + "revision_id": 1, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + if data["title"] != "Untitled" { + t.Fatalf("title = %v, want Untitled", data["title"]) + } +} + +// TestSlidesCreateMissingPresentationID verifies the error when the API returns no xml_presentation_id. +func TestSlidesCreateMissingPresentationID(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "revision_id": 1, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Missing ID", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error when xml_presentation_id is missing, got nil") + } + if !strings.Contains(err.Error(), "xml_presentation_id") { + t.Fatalf("error = %q, want mention of xml_presentation_id", err.Error()) + } +} + +// TestSlidesCreateWithSlides verifies that slides +create with --slides creates the presentation and adds slides. +func TestSlidesCreateWithSlides(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_with_slides", + "revision_id": 1, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "slide_id": "slide_001", + "revision_id": 2, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "slide_id": "slide_002", + "revision_id": 3, + }, + }, + }) + + slidesJSON := `["",""]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "With Slides", + "--slides", slidesJSON, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + if data["xml_presentation_id"] != "pres_with_slides" { + t.Fatalf("xml_presentation_id = %v, want pres_with_slides", data["xml_presentation_id"]) + } + slideIDs, ok := data["slide_ids"].([]interface{}) + if !ok || len(slideIDs) != 2 { + t.Fatalf("slide_ids = %v, want 2 elements", data["slide_ids"]) + } + if slideIDs[0] != "slide_001" || slideIDs[1] != "slide_002" { + t.Fatalf("slide_ids = %v, want [slide_001, slide_002]", slideIDs) + } + if data["slides_added"] != float64(2) { + t.Fatalf("slides_added = %v, want 2", data["slides_added"]) + } +} + +// TestSlidesCreateWithSlidesPartialFailure verifies error reporting when a slide fails to create. +func TestSlidesCreateWithSlidesPartialFailure(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_partial", + "revision_id": 1, + }, + }, + }) + // First slide succeeds + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "slide_id": "slide_ok", + "revision_id": 2, + }, + }, + }) + // Second slide fails + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide", + Body: map[string]interface{}{ + "code": 400, + "msg": "invalid xml", + }, + }) + + slidesJSON := `["",""]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Partial", + "--slides", slidesJSON, + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for partial failure, got nil") + } + errMsg := err.Error() + if !strings.Contains(errMsg, "pres_partial") { + t.Fatalf("error should contain presentation ID, got: %s", errMsg) + } + if !strings.Contains(errMsg, "slide 2/2") { + t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg) + } + if !strings.Contains(errMsg, "1 slide(s) added") { + t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg) + } +} + +// TestSlidesCreateWithSlidesInvalidJSON verifies validation rejects non-JSON slides input. +func TestSlidesCreateWithSlidesInvalidJSON(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Bad JSON", + "--slides", "not json", + "--as", "user", + }) + if err == nil { + t.Fatal("expected validation error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "--slides invalid JSON") { + t.Fatalf("error = %q, want --slides invalid JSON mention", err.Error()) + } +} + +// TestSlidesCreateWithSlidesExceedsMax verifies validation rejects arrays exceeding the limit. +func TestSlidesCreateWithSlidesExceedsMax(t *testing.T) { + t.Parallel() + + // Build a JSON array with 11 elements (exceeds maxSlidesPerCreate = 10) + elems := make([]string, 11) + for i := range elems { + elems[i] = `""` //nolint:goconst + } + slidesJSON := "[" + strings.Join(elems, ",") + "]" + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Too Many", + "--slides", slidesJSON, + "--as", "user", + }) + if err == nil { + t.Fatal("expected validation error for exceeding max, got nil") + } + if !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf("error = %q, want 'exceeds maximum' mention", err.Error()) + } +} + +// TestSlidesCreateWithSlidesEmptyArray verifies that --slides '[]' behaves like no --slides. +func TestSlidesCreateWithSlidesEmptyArray(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_empty_slides", + "revision_id": 1, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "Empty Slides", + "--slides", "[]", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + if data["xml_presentation_id"] != "pres_empty_slides" { + t.Fatalf("xml_presentation_id = %v, want pres_empty_slides", data["xml_presentation_id"]) + } + if _, ok := data["slide_ids"]; ok { + t.Fatalf("did not expect slide_ids for empty slides array") + } + if _, ok := data["slides_added"]; ok { + t.Fatalf("did not expect slides_added for empty slides array") + } +} + +// TestSlidesCreateWithSlidesDryRun verifies dry-run output shows multi-step labels. +func TestSlidesCreateWithSlidesDryRun(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + slidesJSON := `["",""]` + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "DryRun Slides", + "--slides", slidesJSON, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "[1/3]") { + t.Fatalf("dry-run should contain [1/3] step label, got: %s", out) + } + if !strings.Contains(out, "[2/3]") { + t.Fatalf("dry-run should contain [2/3] step label, got: %s", out) + } + if !strings.Contains(out, "[3/3]") { + t.Fatalf("dry-run should contain [3/3] step label, got: %s", out) + } + if !strings.Contains(out, "xml_presentation_id") { + t.Fatalf("dry-run should contain placeholder xml_presentation_id, got: %s", out) + } +} + +// TestSlidesCreateWithoutSlidesUnchanged verifies existing behavior when --slides is not passed. +func TestSlidesCreateWithoutSlidesUnchanged(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", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "xml_presentation_id": "pres_no_slides", + "revision_id": 1, + }, + }, + }) + + err := runSlidesCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "No Slides", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSlidesCreateEnvelope(t, stdout) + if data["xml_presentation_id"] != "pres_no_slides" { + t.Fatalf("xml_presentation_id = %v, want pres_no_slides", data["xml_presentation_id"]) + } + if data["title"] != "No Slides" { + t.Fatalf("title = %v, want No Slides", data["title"]) + } + if _, ok := data["slide_ids"]; ok { + t.Fatalf("did not expect slide_ids when --slides not passed") + } + if _, ok := data["slides_added"]; ok { + t.Fatalf("did not expect slides_added when --slides not passed") + } + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode") + } +} + +// TestXmlEscape verifies that XML special characters are properly escaped. +func TestXmlEscape(t *testing.T) { + t.Parallel() + tests := []struct { + input, want string + }{ + {"hello", "hello"}, + {"a&b", "a&b"}, + {"