mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
39 Commits
feat/slide
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0eaed3354 | ||
|
|
2424d05b01 | ||
|
|
cf9e8d512d | ||
|
|
9d7f1e4e6b | ||
|
|
be7c05cc97 | ||
|
|
9a85ffb4d2 | ||
|
|
ff65e614e7 | ||
|
|
9f2fe50f4a | ||
|
|
7d1164dcb4 | ||
|
|
2362437de9 | ||
|
|
8a5c1dc547 | ||
|
|
4229ea7735 | ||
|
|
72c61cc59e | ||
|
|
33458e6770 | ||
|
|
35446837a2 | ||
|
|
9fa28be312 | ||
|
|
bca7f7d30d | ||
|
|
6764949014 | ||
|
|
eb3ace1427 | ||
|
|
8f0d0725fc | ||
|
|
7121ff1e2a | ||
|
|
431160a204 | ||
|
|
3e430dd821 | ||
|
|
9efa8b3b69 | ||
|
|
81c3736da2 | ||
|
|
6cbb9d68b8 | ||
|
|
f334cc9b34 | ||
|
|
d2452b7f9c | ||
|
|
0552c5c595 | ||
|
|
0f88409ab8 | ||
|
|
2cfe090c1d | ||
|
|
6ff02ea10c | ||
|
|
46c99cb878 | ||
|
|
8939bff9c5 | ||
|
|
736db1ce72 | ||
|
|
9b9ac8759e | ||
|
|
fdcd9f6dde | ||
|
|
e9fde3e8f7 | ||
|
|
8d061ea3bd |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,40 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
|
||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||
- **event**: Support VC meeting lifecycle events (#1632)
|
||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||
|
||||
### Tests
|
||||
|
||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Reduce public content false positives
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
@@ -1299,8 +1265,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Affordance
|
||||
|
||||
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
||||
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
||||
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
||||
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
|
||||
## Format
|
||||
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
Fetch the full content of a single message by id.
|
||||
|
||||
### Avoid when
|
||||
- Reading several at once → use [[messages batch_get]]
|
||||
|
||||
### Prerequisites
|
||||
- message_id from [[messages list]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Fetch one message**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
||||
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
@@ -1,19 +0,0 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
### Avoid when
|
||||
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
||||
|
||||
### Tips
|
||||
- Off by default — set include_personal_status / include_description to true under query_option
|
||||
- ids in user_ids must match --user-id-type (default open_id)
|
||||
|
||||
### Examples
|
||||
|
||||
**Bulk-query status and signature**
|
||||
```bash
|
||||
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
||||
```
|
||||
@@ -67,21 +67,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
||||
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
||||
|
||||
Prefer the typed domain command when one exists — it validates parameters,
|
||||
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
||||
guidance that this raw command does not. If a domain command covers your task
|
||||
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
||||
|
||||
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
||||
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
||||
|
||||
Examples:
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -171,10 +170,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
// Root-only usage template (curated Usage synopsis + skills footer); see
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
@@ -195,7 +190,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -211,8 +205,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
|
||||
@@ -10,22 +10,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -39,8 +27,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -71,15 +57,9 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if key, ok := row["key"].(string); ok {
|
||||
gotKeys[key] = row
|
||||
}
|
||||
}
|
||||
var foundTask bool
|
||||
for key, row := range gotKeys {
|
||||
if key == "task.task.update_user_access_v2" {
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
@@ -89,12 +69,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := gotKeys[want]; !ok {
|
||||
t.Errorf("JSON list output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,45 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["key"] != key {
|
||||
t.Errorf("key = %v, want %s", payload["key"], key)
|
||||
}
|
||||
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
||||
}
|
||||
properties, ok := resolved["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
||||
}
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["end_time"]; ok {
|
||||
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
127
cmd/root.go
127
cmd/root.go
@@ -11,11 +11,9 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
@@ -30,60 +28,43 @@ import (
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>
|
||||
|
||||
EXAMPLES (one per command style, in order of preference):
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
lark-cli calendar +agenda
|
||||
|
||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||
// cobra so the command groups and flags are untouched.
|
||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||
# List calendar events
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli -g -y
|
||||
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
Issues: https://github.com/larksuite/cli/issues
|
||||
Docs: https://open.feishu.cn/document/
|
||||
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
`
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
@@ -548,49 +529,6 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// Root command help groups, so an agent sees content domains, agent tooling, and
|
||||
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
||||
const (
|
||||
groupDomains = "lark-domains"
|
||||
groupTooling = "agent-tooling"
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// groupRootCommands classifies root's direct children into the help groups,
|
||||
// called once after all commands are registered. Unclassified commands fall to
|
||||
// cobra's "Additional Commands" section.
|
||||
func groupRootCommands(root *cobra.Command) {
|
||||
root.AddGroup(
|
||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
if c.GroupID != "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case tooling[c.Name()]:
|
||||
c.GroupID = groupTooling
|
||||
case management[c.Name()]:
|
||||
c.GroupID = groupManagement
|
||||
case isLarkDomain(c):
|
||||
c.GroupID = groupDomains
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||
func isLarkDomain(c *cobra.Command) bool {
|
||||
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
||||
return true
|
||||
}
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||
@@ -672,17 +610,6 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
}
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
|
||||
@@ -76,13 +76,11 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
}
|
||||
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,211 +4,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
|
||||
// skill pointer) to a top-level Lark domain's description, returning false for
|
||||
// anything that is not such a domain. Built lazily at help time because
|
||||
// shortcuts attach after service registration. skillFS (nil-safe) gates the
|
||||
// skill pointer.
|
||||
//
|
||||
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
|
||||
// consume <EventKey>'…"); service domains carry only a Short at this point, so
|
||||
// we fall back to it. The pristine base is captured once into an annotation so
|
||||
// re-rendering does not append the guidance twice.
|
||||
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if cmd.Annotations[schemaPathAnnotation] != "" {
|
||||
return false // a method command
|
||||
}
|
||||
// Direct child of root only — so Domain() reads this command's own tag, and
|
||||
// nested resource groups are excluded.
|
||||
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
|
||||
return false
|
||||
}
|
||||
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
|
||||
return false
|
||||
}
|
||||
if !cmd.HasAvailableSubCommands() {
|
||||
return false
|
||||
}
|
||||
|
||||
hasShortcuts, hasResources := false, false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(c.Name(), "+") {
|
||||
hasShortcuts = true
|
||||
} else {
|
||||
hasResources = true
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(domainHelpBase(cmd))
|
||||
if hasShortcuts && hasResources { // routing only matters when both styles exist
|
||||
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
||||
}
|
||||
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
||||
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
||||
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// domainHelpBase returns the description to seed domain help with — the
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
if base == "" {
|
||||
base = cmd.Short
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
return base
|
||||
}
|
||||
|
||||
// methodLong is the build-time Long (description + schema pointer +
|
||||
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
|
||||
// so command construction never parses the overlay.
|
||||
func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
// methodLong composes a method command's long help in one place: the
|
||||
// description, the affordance guidance block (when the method has one), the
|
||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||
// the flag list.
|
||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
}
|
||||
schemaPath, ok := ann[schemaPathAnnotation]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
|
||||
// it carries none.
|
||||
func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordance(meta.Method{Affordance: raw})
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block, or "" when it
|
||||
// has none. Sections are joined with blank lines so they scan as distinct groups.
|
||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||
// the method carries no affordance. It reads the single typed model
|
||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sections []string
|
||||
var b strings.Builder
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
@@ -219,18 +49,15 @@ func renderAffordance(m meta.Method) string {
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
var s strings.Builder
|
||||
fmt.Fprintf(&s, "%s:\n", title)
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&s, " • %s\n", it)
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
}
|
||||
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.AvoidWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
bullets("Tips", a.Tips)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
@@ -244,13 +71,10 @@ func renderAffordance(m meta.Method) string {
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
for _, ext := range a.Extensions {
|
||||
bullets(ext.Label, ext.Items)
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.Join(sections, "\n\n")
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
@@ -8,18 +8,15 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"avoid_when": ["群已解散"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"tips": ["富文本用 msg_type=post"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
@@ -32,7 +29,6 @@ func TestRenderAffordance(t *testing.T) {
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Tips:", "富文本用 msg_type=post",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
@@ -52,12 +48,9 @@ func TestRenderAffordance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Affordance is rendered lazily (at --help time) rather than baked into the
|
||||
// command's Long, so building a command never carries the affordance block —
|
||||
// even for a method whose metadata happens to declare one.
|
||||
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
@@ -66,120 +59,14 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if strings.Contains(cmd.Long, "Examples:") {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||
}
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
|
||||
// lookup and renders it; commands without a ref render nothing.
|
||||
func TestRenderAffordanceForCmd(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service != "im" || methodID != "messages.create" {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
|
||||
block := RenderAffordanceForCmd(cmd)
|
||||
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
|
||||
// No overlay for this method id -> empty block.
|
||||
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
|
||||
if got := RenderAffordanceForCmd(cmd2); got != "" {
|
||||
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp composes the guidance into Long at the top: description,
|
||||
// then the affordance block, then the full-schema pointer — so an agent reads
|
||||
// when-to-use/examples before the flag list.
|
||||
func TestPrepareMethodHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
// Description leads; affordance block sits above the schema pointer.
|
||||
descAt := strings.Index(long, "发送消息")
|
||||
useAt := strings.Index(long, "When to use:")
|
||||
exAt := strings.Index(long, "Examples:")
|
||||
schemaAt := strings.Index(long, "Full parameter schema:")
|
||||
if descAt != 0 {
|
||||
t.Errorf("description should lead Long, got:\n%s", long)
|
||||
}
|
||||
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
|
||||
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "event", Short: short, Long: long}
|
||||
cmdmeta.SetDomain(dom, "event")
|
||||
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
return dom
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
|
||||
dom := domainCmd("Consume and manage real-time events", long)
|
||||
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, long) {
|
||||
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
|
||||
}
|
||||
if !strings.Contains(dom.Long, "Risk levels") {
|
||||
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
|
||||
}
|
||||
|
||||
// Re-rendering must not append the guidance a second time.
|
||||
PrepareDomainHelp(dom, nil)
|
||||
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
|
||||
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
|
||||
// A method with no affordance adds no guidance block.
|
||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||
if strings.Contains(cmd2.Long, "Examples:") {
|
||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,8 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
// The redundant "<name>, required|optional." prefix is gone: required-ness is
|
||||
// carried by the Required:/Optional: subheadings, and the snake-case --params
|
||||
// key by the schema envelope — so it isn't echoed on every flag line.
|
||||
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
|
||||
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
|
||||
@@ -30,11 +30,6 @@ func fieldFacts(f meta.Field) []string {
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if f.CanonicalType() == "boolean" {
|
||||
// cobra shows no type word for bools and swallows a separate value as a
|
||||
// positional, so spell out the presence-only contract.
|
||||
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
@@ -47,15 +42,20 @@ func fieldFacts(f meta.Field) []string {
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line: the field's facts
|
||||
// joined inline. Required/optional is not repeated here — the grouped help's
|
||||
// Required:/Optional: subheadings already partition the flags — and the
|
||||
// snake-case --params key is carried by the schema envelope (each param's
|
||||
// property + "flag") and the params-only addendum, so it isn't echoed on every
|
||||
// line either. Returns "" when the field has no facts (cobra then shows the bare
|
||||
// flag with its type).
|
||||
// paramFlagUsage renders the typed param flag's help line:
|
||||
//
|
||||
// <param_name>, required|optional[. <fact>]...
|
||||
//
|
||||
// It leads with the canonical underscore param name (the key this flag
|
||||
// overrides in --params) and required/optional, then joins the field's facts
|
||||
// inline.
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
return strings.Join(fieldFacts(f), ". ")
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
@@ -103,23 +103,8 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
|
||||
// cross-reference is dropped first (see cutDocRef).
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) }
|
||||
|
||||
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
|
||||
// On the compact flag line the markdown link's URL is stripped, so the
|
||||
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
|
||||
// so a subject that runs straight into the phrase isn't orphaned.
|
||||
var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
|
||||
|
||||
// cutDocRef truncates s at the first doc-reference breadcrumb.
|
||||
func cutDocRef(s string) string {
|
||||
if loc := docRefRe.FindStringIndex(s); loc != nil {
|
||||
return s[:loc[0]]
|
||||
}
|
||||
return s
|
||||
}
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -65,38 +64,15 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
refs := apicatalog.ServiceMethods(svc, nil)
|
||||
|
||||
// Collect each resource's verbs up front so resourceShort can summarize a
|
||||
// resource as its verb list from the first ensureChildCommand call.
|
||||
verbs := map[string][]string{}
|
||||
for _, ref := range refs {
|
||||
key := strings.Join(ref.ResourcePath, ".")
|
||||
verbs[key] = append(verbs[key], ref.Method.Name)
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
resCmd := svcCmd
|
||||
var path []string
|
||||
for _, seg := range ref.ResourcePath {
|
||||
path = append(path, seg)
|
||||
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// resourceShort summarizes a resource as its sorted verb list, or the
|
||||
// "<name> operations" placeholder for an intermediate group with no methods.
|
||||
func resourceShort(seg string, verbs []string) string {
|
||||
if len(verbs) == 0 {
|
||||
return seg + " operations"
|
||||
}
|
||||
sorted := append([]string(nil), verbs...)
|
||||
sort.Strings(sorted)
|
||||
return strings.Join(sorted, ", ")
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
@@ -201,19 +177,7 @@ type methodCommandSpec struct {
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
paginates bool // method accepts a page_token param (so --page-all is meaningful)
|
||||
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
|
||||
}
|
||||
|
||||
// methodPaginates reports whether a method takes a page_token param, the signal
|
||||
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
|
||||
func methodPaginates(m meta.Method) bool {
|
||||
for _, f := range m.Params() {
|
||||
if f.Name == "page_token" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
@@ -222,7 +186,6 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
serviceName: ref.Service.Name,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
@@ -230,7 +193,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
paginates: methodPaginates(m),
|
||||
affordance: renderAffordance(m),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,14 +254,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
// Keep the pagination flags registered (a harmless no-op if passed) but hide
|
||||
// them from help on non-paginating commands, so help doesn't imply a
|
||||
// get/write can paginate.
|
||||
if !spec.paginates {
|
||||
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
@@ -316,11 +271,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
|
||||
// (setMethodHelpData records the coordinates it needs).
|
||||
paramsOnly := opts.binder.paramsOnlyHelp()
|
||||
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
|
||||
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
|
||||
// Single composition point for Long: description, affordance, schema
|
||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||
// taken, reachable via --params only).
|
||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
@@ -338,11 +292,13 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// Keep the precedence rule on the flag's own one line (not a multi-line
|
||||
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
|
||||
// meaningful when typed flags exist to override.
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
if len(spec.params) > 0 {
|
||||
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Identity string `json:"identity"`
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. It is local-only:
|
||||
// no network calls are made.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Reject an explicit --as that does not resolve to a usable identity, so a
|
||||
// typo like `--as admin` fails clearly instead of echoing back a bogus
|
||||
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
|
||||
// matches how api/service/shortcut commands validate the resolved identity.
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
source := resolveSource(
|
||||
cmd.Flags().Changed("as"),
|
||||
flagAs,
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
formatPretty(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSource derives how the effective identity became effective.
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto-detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict-mode"
|
||||
}
|
||||
return "default-as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
res.OpenID = diag.User.OpenID
|
||||
res.UserName = diag.User.UserName
|
||||
res.TokenStatus = diag.User.TokenStatus
|
||||
if res.TokenStatus == "" {
|
||||
res.TokenStatus = "missing"
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = "No usable user token. Run `lark-cli auth login`."
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// formatPretty writes the human-readable one-glance summary.
|
||||
func formatPretty(w io.Writer, r *whoamiResult) {
|
||||
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
|
||||
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
|
||||
if r.Identity == string(core.AsUser) && r.UserName != "" {
|
||||
if r.OpenID != "" {
|
||||
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
|
||||
} else {
|
||||
fmt.Fprintf(w, "User: %s\n", r.UserName)
|
||||
}
|
||||
}
|
||||
token := r.TokenStatus
|
||||
if !r.Available && r.Hint != "" {
|
||||
token = r.TokenStatus + " — " + r.Hint
|
||||
}
|
||||
// Write the label and value as separate %s args rather than one combined
|
||||
// literal. A single label-colon-value literal trips the public-content
|
||||
// credential scanner as a false-positive credential assignment; splitting
|
||||
// the args avoids it while producing identical output.
|
||||
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changedAs bool
|
||||
flagAs core.Identity
|
||||
autoDetected bool
|
||||
strictForced core.Identity
|
||||
want string
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
|
||||
{"auto detected", false, "", true, "", "auto-detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
|
||||
{"default-as", false, "", false, "", "default-as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "valid" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "ou_x" || r.UserName != "Alice" {
|
||||
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||
t.Fatalf("app context = %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default-as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default-as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "" || r.UserName != "" {
|
||||
t.Fatalf("bot must not carry openId/userName: %#v", r)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_User(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"Profile: my-app (cli_x, lark)",
|
||||
"Identity: user (auto-detect)",
|
||||
"User: Alice (ou_x)",
|
||||
"Token: valid",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_BotNoUserLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
Identity: "bot", IdentitySource: "default-as",
|
||||
Available: true, TokenStatus: "ready",
|
||||
})
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "User:") {
|
||||
t.Errorf("bot output must not contain User: line\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
|
||||
t.Errorf("unexpected bot output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: false, TokenStatus: "missing",
|
||||
Hint: "No usable user token. Run `lark-cli auth login`.",
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Token: missing — No usable user token.") {
|
||||
t.Errorf("expected token line with hint, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_BotJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||
}
|
||||
if got.Profile != "test-profile" {
|
||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||
}
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OpenID != "" {
|
||||
t.Fatalf("bot must not carry openId: %q", got.OpenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||
t.Run("as="+bad, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", bad})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||
}
|
||||
// Lock in the typed validation contract: an unsupported identity must
|
||||
// surface as a *errs.ValidationError on --as, not just any error.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||
}
|
||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||
// command-execution error.
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
)
|
||||
|
||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
|
||||
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
|
||||
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
|
||||
// a new content type is omitted until added to the embed list. The embed must live
|
||||
// in this root package because go:embed cannot reach up out of a package's dir.
|
||||
//
|
||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
|
||||
var embeddedContentFS embed.FS
|
||||
|
||||
// init wires the embedded content into the CLI. It compiles into `go build .` but
|
||||
// not the single-file preview build (`go build ./main.go`), so that build stays
|
||||
// self-contained (shipping no embedded content). Assembly failures warn on stderr
|
||||
// rather than panicking — embedded content is nice-to-have, not load-bearing.
|
||||
func init() {
|
||||
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
||||
} else {
|
||||
cmd.SetEmbeddedSkillContent(sub)
|
||||
}
|
||||
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
||||
} else {
|
||||
affordance.SetSource(sub)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
|
||||
type VCParticipantMeetingJoinedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingJoinedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
schemaType reflect.Type
|
||||
}{
|
||||
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeMeetingStarted,
|
||||
process: processVCParticipantMeetingStarted,
|
||||
},
|
||||
{
|
||||
name: "joined",
|
||||
eventType: eventTypeMeetingJoined,
|
||||
process: processVCParticipantMeetingJoined,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_lifecycle_001",
|
||||
"event_type": "` + tc.eventType + `",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
|
||||
|
||||
if out["type"] != tc.eventType {
|
||||
t.Errorf("type = %q", out["type"])
|
||||
}
|
||||
if out["event_id"] != "ev_vc_lifecycle_001" {
|
||||
t.Errorf("event_id = %q", out["event_id"])
|
||||
}
|
||||
if out["timestamp"] != "1608725989000" {
|
||||
t.Errorf("timestamp = %q", out["timestamp"])
|
||||
}
|
||||
if out["meeting_id"] != "6911188411934433028" {
|
||||
t.Errorf("meeting_id = %q", out["meeting_id"])
|
||||
}
|
||||
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
|
||||
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
|
||||
}
|
||||
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
|
||||
t.Errorf("start_time = %q, want %q", out["start_time"], want)
|
||||
}
|
||||
if _, hasEndTime := out["end_time"]; hasEndTime {
|
||||
t.Error("end_time should not be present in started/joined output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_lifecycle_002",
|
||||
"event_type": "` + tc.eventType + `",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
|
||||
switch tc.eventType {
|
||||
case eventTypeMeetingStarted:
|
||||
var started VCParticipantMeetingStartedOutput
|
||||
if err := json.Unmarshal(out, &started); err != nil {
|
||||
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
|
||||
}
|
||||
if started.StartTime != "" {
|
||||
t.Errorf("StartTime = %q, want empty string", started.StartTime)
|
||||
}
|
||||
case eventTypeMeetingJoined:
|
||||
var joined VCParticipantMeetingJoinedOutput
|
||||
if err := json.Unmarshal(out, &joined); err != nil {
|
||||
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
|
||||
}
|
||||
if joined.StartTime != "" {
|
||||
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
|
||||
t.Helper()
|
||||
got := runMeetingLifecycleRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
|
||||
type VCParticipantMeetingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingStartedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
|
||||
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
@@ -32,38 +30,6 @@ const (
|
||||
// Keys returns all VC-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingStarted,
|
||||
DisplayName: "Participant meeting started",
|
||||
Description: "Triggered when a meeting the current user participates in has started",
|
||||
EventType: eventTypeMeetingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeMeetingJoined,
|
||||
DisplayName: "Participant meeting joined",
|
||||
Description: "Triggered when the current user joins a meeting",
|
||||
EventType: eventTypeMeetingJoined,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingJoined,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
|
||||
},
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package affordance is the lazily-loaded store of usage guidance for
|
||||
// service-API methods. The source of truth is one markdown file per service in
|
||||
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
|
||||
// domain owners maintain it next to skills/ and shortcuts/. A service is read
|
||||
// and parsed at most once, on first access, so normal command execution never
|
||||
// touches it.
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
byService = map[string]map[string]json.RawMessage{}
|
||||
tried = map[string]bool{}
|
||||
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
|
||||
)
|
||||
|
||||
// SetSource installs the markdown guidance tree (the top-level affordance/
|
||||
// directory) as the source. Called once at startup before any lookup; clears
|
||||
// the parse cache so re-sourcing (e.g. in tests) takes effect.
|
||||
func SetSource(fsys fs.FS) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
mdSource = fsys
|
||||
byService = map[string]map[string]json.RawMessage{}
|
||||
tried = map[string]bool{}
|
||||
}
|
||||
|
||||
// For returns the raw affordance overlay for one method, loading the owning
|
||||
// service on first access. ok is false when there is no entry (absent source,
|
||||
// parse failure, or unknown method all collapse to "no guidance").
|
||||
func For(service, methodID string) (json.RawMessage, bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !tried[service] {
|
||||
tried[service] = true
|
||||
byService[service] = loadService(service)
|
||||
}
|
||||
raw, ok := byService[service][methodID]
|
||||
return raw, ok && len(raw) > 0
|
||||
}
|
||||
|
||||
// loadService parses a service's markdown guidance into per-method overlays,
|
||||
// marshalling each to JSON so downstream callers keep the same wire shape.
|
||||
func loadService(service string) map[string]json.RawMessage {
|
||||
if mdSource == nil {
|
||||
return nil
|
||||
}
|
||||
src, err := fs.ReadFile(mdSource, service+".md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
|
||||
if b, err := json.Marshal(a); err == nil {
|
||||
m[id] = b
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
|
||||
// list") to its method id ("user_mailbox.message.list") via the registry's
|
||||
// authoritative resource↔id table. Resource names are irregularly pluralised
|
||||
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
|
||||
// space→dot fallback covers domains where the two already coincide.
|
||||
func commandFormResolver(service string) func(string) string {
|
||||
byForm := map[string]string{}
|
||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
||||
if svc.Name != service {
|
||||
continue
|
||||
}
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||
}
|
||||
break
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
return id
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||
// paragraph (use_when) and a fenced example.
|
||||
const fixtureMD = "# approval\n" +
|
||||
"> skill: lark-approval\n\n" +
|
||||
"## instances cc\n" +
|
||||
"把一个审批实例抄送给指定用户。\n\n" +
|
||||
"### Examples\n\n" +
|
||||
"**抄送给用户**\n" +
|
||||
"```bash\n" +
|
||||
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
|
||||
"```\n\n" +
|
||||
"## instances get\n" +
|
||||
"查询某审批实例详情。\n\n" +
|
||||
"### Examples\n\n" +
|
||||
"**按 code 查询**\n" +
|
||||
"```bash\n" +
|
||||
"lark-cli approval instances get --instance-code \"x\"\n" +
|
||||
"```\n"
|
||||
|
||||
func TestFor(t *testing.T) {
|
||||
prev := mdSource
|
||||
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
|
||||
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
|
||||
|
||||
// A seeded method in a seeded service resolves to its overlay.
|
||||
raw, ok := For("approval", "instances.cc")
|
||||
if !ok {
|
||||
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
|
||||
}
|
||||
var a struct {
|
||||
UseWhen []string `json:"use_when"`
|
||||
Examples []struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"examples"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("overlay is not valid affordance JSON: %v", err)
|
||||
}
|
||||
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
|
||||
t.Errorf("overlay missing use_when/examples: %s", raw)
|
||||
}
|
||||
|
||||
// Misses: unknown method in a known service, and an unknown service, both
|
||||
// resolve to ok=false (no panic, no error) so callers treat them as "no
|
||||
// guidance".
|
||||
if _, ok := For("approval", "instances.no_such_method"); ok {
|
||||
t.Error("unknown method should be ok=false")
|
||||
}
|
||||
if _, ok := For("no_such_service", "x.y"); ok {
|
||||
t.Error("unknown service should be ok=false")
|
||||
}
|
||||
|
||||
// A second lookup of the same service is served from cache (parsed at most
|
||||
// once) and stays consistent.
|
||||
if _, ok := For("approval", "instances.get"); !ok {
|
||||
t.Error("second lookup in a cached service should still resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// Non-bullet paragraph lines under any section are preserved as items, not
|
||||
// dropped (regression: they previously only updated pending, lost without a fence).
|
||||
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
||||
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
|
||||
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
|
||||
a, ok := got["foo.bar"]
|
||||
if !ok {
|
||||
t.Fatal("method not parsed")
|
||||
}
|
||||
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
|
||||
t.Errorf("Tips paragraph dropped: %v", a.Tips)
|
||||
}
|
||||
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
|
||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package affordance
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
|
||||
//
|
||||
// # domain optional `> skill: <name>` applied to every method
|
||||
// ## command e.g. `instances get`
|
||||
// <lead paragraph> -> use_when (when this command is right)
|
||||
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
|
||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||
// ### Tips -> tips
|
||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||
//
|
||||
// Parsing is lazy and cached (see For), so the constrained grammar is read at
|
||||
// most once per domain.
|
||||
|
||||
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
|
||||
|
||||
// standardSection maps a section heading to its typed Affordance field; any
|
||||
// other heading becomes an extension.
|
||||
var standardSection = map[string]string{
|
||||
"Avoid when": "avoid_when",
|
||||
"Prerequisites": "prerequisites",
|
||||
"Tips": "tips",
|
||||
"Examples": "examples",
|
||||
}
|
||||
|
||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||
|
||||
// headingToKey maps a command heading ("instances get") to its affordance key
|
||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||
func headingToKey(h string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
label string
|
||||
items []string
|
||||
cases []meta.AffordanceCase
|
||||
}
|
||||
|
||||
// parseDomainMD parses one domain's markdown into per-method Affordance values,
|
||||
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
|
||||
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
|
||||
// space→dot rule (valid only where the command form already equals the id).
|
||||
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
|
||||
if resolve == nil {
|
||||
resolve = headingToKey
|
||||
}
|
||||
out := map[string]meta.Affordance{}
|
||||
|
||||
var skill, curKey string
|
||||
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
|
||||
var secs []*mdSection
|
||||
var sec *mdSection
|
||||
var pending string
|
||||
var fence []string
|
||||
inFence := false
|
||||
|
||||
assemble := func() {
|
||||
if curKey == "" {
|
||||
return
|
||||
}
|
||||
if len(para) > 0 {
|
||||
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
|
||||
para = nil
|
||||
}
|
||||
var a meta.Affordance
|
||||
if len(useWhen) > 0 {
|
||||
a.UseWhen = useWhen
|
||||
}
|
||||
for _, s := range secs {
|
||||
switch standardSection[s.label] {
|
||||
case "avoid_when":
|
||||
a.AvoidWhen = s.items
|
||||
case "prerequisites":
|
||||
a.Prerequisites = s.items
|
||||
case "tips":
|
||||
a.Tips = s.items
|
||||
case "examples":
|
||||
a.Examples = s.cases
|
||||
default:
|
||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||
}
|
||||
}
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
|
||||
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
|
||||
|
||||
// flushPending appends a non-bullet paragraph line that was not consumed as
|
||||
// an example description (i.e. no fence followed) to the current section's
|
||||
// items, so prose under any section is preserved rather than dropped.
|
||||
flushPending := func() {
|
||||
if sec != nil && pending != "" {
|
||||
sec.items = append(sec.items, linkToBacktick(pending))
|
||||
pending = ""
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(string(src), "\n") {
|
||||
line := strings.TrimRight(raw, "\r")
|
||||
t := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "## "):
|
||||
flushPending()
|
||||
assemble()
|
||||
curKey = resolve(line[3:])
|
||||
reset()
|
||||
continue
|
||||
case strings.HasPrefix(line, "# "):
|
||||
continue
|
||||
case strings.HasPrefix(t, "> skill:"):
|
||||
skill = strings.TrimSpace(t[len("> skill:"):])
|
||||
continue
|
||||
case strings.HasPrefix(line, "### "):
|
||||
flushPending()
|
||||
sec = &mdSection{label: strings.TrimSpace(line[4:])}
|
||||
secs = append(secs, sec)
|
||||
pending, fence, inFence = "", nil, false
|
||||
continue
|
||||
}
|
||||
if curKey == "" {
|
||||
continue
|
||||
}
|
||||
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
|
||||
if t == "" {
|
||||
if len(para) > 0 {
|
||||
useWhen = append(useWhen, strings.Join(para, " "))
|
||||
para = nil
|
||||
}
|
||||
} else {
|
||||
para = append(para, t)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// inside a section: a fenced block is an example command; otherwise the
|
||||
// shape follows the writing (bullet item vs **description** before a fence).
|
||||
if strings.HasPrefix(t, "```") {
|
||||
if !inFence {
|
||||
inFence, fence = true, nil
|
||||
} else {
|
||||
inFence = false
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
fence = append(fence, line)
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(t, "-") {
|
||||
flushPending()
|
||||
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
|
||||
} else if t != "" {
|
||||
flushPending()
|
||||
pending = strings.Trim(t, "* ")
|
||||
}
|
||||
}
|
||||
flushPending()
|
||||
assemble()
|
||||
return out
|
||||
}
|
||||
@@ -5,39 +5,30 @@ package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||
// model the envelope renderer and the command help both parse, so the
|
||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
// Affordance is the hand-authored usage guidance overlaid on a method: when to
|
||||
// use it, when not to, prerequisites, few-shot examples, and related methods.
|
||||
// It is the single typed model of the affordance shape; the envelope renderer
|
||||
// and the command help both parse through ParsedAffordance so the vocabulary
|
||||
// is defined once. The JSON tags double as the envelope's wire shape.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Tips []string `json:"tips,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Extensions []AffordanceSection `json:"extensions,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
|
||||
// AffordanceCase is one few-shot example: a one-line description and a
|
||||
// ready-to-run command.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// AffordanceSection is a custom guidance section: any heading beyond the
|
||||
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
|
||||
// here with its label preserved, so authors can add sections without code
|
||||
// changes.
|
||||
type AffordanceSection struct {
|
||||
Label string `json:"label"`
|
||||
Items []string `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
|
||||
// malformed, or wholly empty — callers treat all three as "no guidance".
|
||||
// ParsedAffordance decodes the method's raw affordance overlay into the typed
|
||||
// Affordance. ok is false when the method carries no affordance, the JSON is
|
||||
// malformed, or every section is empty — so callers can treat "no guidance"
|
||||
// uniformly.
|
||||
func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if len(m.Affordance) == 0 {
|
||||
return Affordance{}, false
|
||||
@@ -46,7 +37,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if json.Unmarshal(m.Affordance, &a) != nil {
|
||||
return Affordance{}, false
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
return Affordance{}, false
|
||||
}
|
||||
return a, true
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
notOK := map[string]string{
|
||||
"empty payload": ``,
|
||||
"empty object": `{}`,
|
||||
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
|
||||
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
|
||||
"malformed string": `"not an object"`,
|
||||
"malformed number": `42`,
|
||||
"nested type mismatch": `{"examples":"should be a list"}`,
|
||||
@@ -35,9 +35,8 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
// Populated affordance parses with all fields.
|
||||
raw := `{
|
||||
"use_when": ["需要拿到当前用户的主日历 ID"],
|
||||
"avoid_when": ["已知具体 calendar_id"],
|
||||
"do_not_use_when": ["已知具体 calendar_id"],
|
||||
"prerequisites": ["user 身份登录"],
|
||||
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
|
||||
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
||||
"related": ["calendars.list"]
|
||||
}`
|
||||
@@ -48,22 +47,10 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
|
||||
t.Errorf("Tips = %v", a.Tips)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
|
||||
// A method whose only guidance is Tips still parses as populated.
|
||||
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
|
||||
if !ok {
|
||||
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
|
||||
}
|
||||
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
|
||||
t.Errorf("Tips = %v", tipsOnly.Tips)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,7 @@ func namedPlaceholderValue(value string) bool {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") ||
|
||||
allXPlaceholder(value) ||
|
||||
conventionalNamedPlaceholderValue(value)
|
||||
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
@@ -83,41 +81,6 @@ func allXPlaceholder(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func conventionalNamedPlaceholderValue(value string) bool {
|
||||
if !delimitedPlaceholderIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ReplaceAll(value, "-", "_")
|
||||
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
|
||||
return conventionalCredentialPlaceholderName(rest)
|
||||
}
|
||||
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
|
||||
return conventionalCredentialPlaceholderName(rest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func conventionalCredentialPlaceholderName(value string) bool {
|
||||
switch value {
|
||||
case "api_key",
|
||||
"access_key",
|
||||
"private_key",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"token",
|
||||
"webhook",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"bearer_token",
|
||||
"session_token",
|
||||
"client_secret":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func urlWithAnglePlaceholder(value string) bool {
|
||||
if !strings.Contains(value, "://") ||
|
||||
!strings.Contains(value, "<") ||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -65,15 +63,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
if isSchemaDottedIdentifier(line, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||
}
|
||||
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
|
||||
if isPlaceholderBearerHeader(match) {
|
||||
continue
|
||||
}
|
||||
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
@@ -396,6 +391,10 @@ func credentialNameFragment(value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isSchemaDottedIdentifier(line, match string) bool {
|
||||
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
@@ -405,40 +404,6 @@ func isNonSecretLiteralValue(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isJWTToken(value string) bool {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
header, err := decodeBase64URLSegment(parts[0])
|
||||
if err != nil || !json.Valid(header) {
|
||||
return false
|
||||
}
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal(header, &fields); err != nil {
|
||||
return false
|
||||
}
|
||||
alg, ok := fields["alg"].(string)
|
||||
return ok && alg != ""
|
||||
}
|
||||
|
||||
func decodeBase64URLSegment(value string) ([]byte, error) {
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func isPlaceholderBearerHeader(match string) bool {
|
||||
normalized := strings.ToLower(match)
|
||||
idx := strings.LastIndex(normalized, "bearer ")
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
value := strings.TrimSpace(match[idx+len("bearer "):])
|
||||
return isPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func isWebhookCredentialKey(key string) bool {
|
||||
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
||||
}
|
||||
@@ -776,12 +741,7 @@ func sanitizeSemanticExcerpt(text string) string {
|
||||
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
||||
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
||||
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
||||
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
if isJWTToken(match) {
|
||||
return "<jwt-like-token>"
|
||||
}
|
||||
return match
|
||||
})
|
||||
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
|
||||
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
|
||||
got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
||||
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
||||
if !findingRules(got)["public_content_detector_fingerprint"] {
|
||||
t.Fatalf("expected detector fingerprint finding, got %#v", got)
|
||||
}
|
||||
@@ -549,7 +549,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
|
||||
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
|
||||
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
|
||||
"inPrivateKey = true",
|
||||
"inPrivateKey = false",
|
||||
"hasPrivateKey: false",
|
||||
@@ -725,7 +725,7 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
@@ -734,7 +734,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -743,7 +743,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
||||
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
|
||||
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
|
||||
"AppSecret: stored,",
|
||||
"AccessToken: result.Token.AccessToken,",
|
||||
`token := runtime.Str("token")`,
|
||||
@@ -756,7 +756,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
|
||||
"def normalize_token(value: str) -> str:",
|
||||
" token = rest[index]",
|
||||
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
|
||||
@@ -771,7 +771,7 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
`<img token="..." url="https://..." width="..." height="..."/>`,
|
||||
`<sheet token="..." sheet-id="...">`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -783,7 +783,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
||||
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_jwt_like_token" {
|
||||
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
|
||||
@@ -791,38 +791,8 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{
|
||||
"| Method | Permission |",
|
||||
"| --- | --- |",
|
||||
"| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |",
|
||||
"| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |",
|
||||
"| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
|
||||
"| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |",
|
||||
"| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_jwt_like_token" {
|
||||
t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) {
|
||||
got := ScanFile("docs/api.md", []byte(strings.Join([]string{
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"corehr:employment.international_assignment.custom_field.apaas_id__c:read",
|
||||
"user_mailbox.sent_messages.get_recall_detail queries recall detail.",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_jwt_like_token" {
|
||||
t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"1704067200"}`,
|
||||
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -835,7 +805,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -851,7 +821,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "block_token": "boardXXXX" }`,
|
||||
`{ "resource_token": "doc_token_or_url" }`,
|
||||
`{ "token": "canonical_token" }`,
|
||||
@@ -871,7 +841,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -887,7 +857,7 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
||||
}
|
||||
@@ -988,19 +958,6 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/auth.md", []byte(strings.Join([]string{
|
||||
"Authorization: Bearer YOUR_ACCESS_TOKEN",
|
||||
`{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`,
|
||||
"Authorization: Bearer <access-token>",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_bearer_header" {
|
||||
t.Fatalf("bearer placeholder should not be bearer finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
||||
token := "abcdefghijklmnopqrstuvwxyz"
|
||||
text := "private launch plan for internal rollout on Friday\n" +
|
||||
@@ -1018,22 +975,6 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) {
|
||||
text := "private launch plan for internal rollout on Friday\n" +
|
||||
"Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n"
|
||||
|
||||
got := semanticCandidate("docs/public.md", "file", text, 1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
|
||||
}
|
||||
if strings.Contains(got[0].Excerpt, "<jwt-like-token>") {
|
||||
t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0])
|
||||
}
|
||||
if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") {
|
||||
t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
|
||||
text := strings.Join([]string{
|
||||
"Generated with automated code assistant",
|
||||
@@ -1071,37 +1012,6 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: YOUR_CLIENT_SECRET",
|
||||
"api_key: YOUR_API_KEY",
|
||||
"password: YOUR_PASSWORD",
|
||||
"access_token: ACCESS_TOKEN_HERE",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
@@ -25,7 +22,7 @@ func Convert(f meta.Field) Property {
|
||||
if f.Type == "file" {
|
||||
p.Format = "binary"
|
||||
}
|
||||
p.Description = normalizeDesc(f.Description)
|
||||
p.Description = f.Description
|
||||
p.Default = f.CoercedDefault()
|
||||
p.Example = f.CoercedExample()
|
||||
p.Minimum = f.MinBound()
|
||||
@@ -55,24 +52,6 @@ func Convert(f meta.Field) Property {
|
||||
return p
|
||||
}
|
||||
|
||||
var (
|
||||
sepRunRe = regexp.MustCompile(`[;;]{2,}`)
|
||||
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
|
||||
)
|
||||
|
||||
// normalizeDesc de-crufts a meta_data description for the envelope — strips
|
||||
// markdown emphasis and collapses doubled separators/spaces — but keeps content
|
||||
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
|
||||
func normalizeDesc(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.ReplaceAll(s, "**", "")
|
||||
s = sepRunRe.ReplaceAllString(s, "; ")
|
||||
s = spaceRunRe.ReplaceAllString(s, " ")
|
||||
return strings.TrimRight(s, " ;;。.,,、\n")
|
||||
}
|
||||
|
||||
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
|
||||
// arrays for the envelope. enumDescriptions is nil unless at least one value
|
||||
// carries a description (so the bare-enum form stays values-only), keeping the
|
||||
@@ -107,18 +86,6 @@ func propsOf(fields []meta.Field) *OrderedProps {
|
||||
return op
|
||||
}
|
||||
|
||||
// paramPropsOf is propsOf for the params section: each property also carries
|
||||
// its CLI flag (--kebab-name).
|
||||
func paramPropsOf(fields []meta.Field) *OrderedProps {
|
||||
op := &OrderedProps{}
|
||||
for _, f := range fields {
|
||||
p := Convert(f)
|
||||
p.Flag = "--" + f.FlagName()
|
||||
op.Set(f.Name, p)
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
// requiredOf returns the alphabetized names of the required fields.
|
||||
func requiredOf(fields []meta.Field) []string {
|
||||
var required []string
|
||||
@@ -141,17 +108,16 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
||||
Properties: &OrderedProps{},
|
||||
}
|
||||
|
||||
addInputObject(is, "params", "", m.Params(), true, "")
|
||||
addInputObject(is, "data", "", m.Data(), false, "--data")
|
||||
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
|
||||
addInputObject(is, "params", "", m.Params())
|
||||
addInputObject(is, "data", "", m.Data())
|
||||
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
|
||||
|
||||
if m.Risk == core.RiskHighRiskWrite {
|
||||
falseVal := false
|
||||
is.Properties.Set("yes", Property{
|
||||
Type: "boolean",
|
||||
Flag: "--yes",
|
||||
Default: falseVal,
|
||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
|
||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,24 +125,20 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
||||
return is
|
||||
}
|
||||
|
||||
// addInputObject adds one section (params/data/file) when it has fields, marking
|
||||
// the section required at top level when any field is. asFlags tags each property
|
||||
// with its --flag (params only); carrier names the section's flag (--data/--file).
|
||||
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
|
||||
// addInputObject adds one named sub-object section (params/data/file) to the
|
||||
// input schema when it has fields: its Properties come from the fields, its
|
||||
// Required lists the mandatory keys, and the section itself is required at top
|
||||
// level when any field is required. Empty sections are skipped.
|
||||
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
props := propsOf(fields)
|
||||
if asFlags {
|
||||
props = paramPropsOf(fields)
|
||||
}
|
||||
req := requiredOf(fields)
|
||||
is.Properties.Set(name, Property{
|
||||
Type: "object",
|
||||
Description: description,
|
||||
Carrier: carrier,
|
||||
Required: req,
|
||||
Properties: props,
|
||||
Properties: propsOf(fields),
|
||||
})
|
||||
if len(req) > 0 {
|
||||
is.Required = append(is.Required, name)
|
||||
@@ -217,13 +179,7 @@ func buildMeta(m meta.Method) *Meta {
|
||||
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
|
||||
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
|
||||
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
|
||||
m := ref.Method
|
||||
// The affordance overlay lives in the CLI, not the metadata; look it up
|
||||
// lazily here (it takes precedence over any affordance the metadata carries).
|
||||
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
|
||||
m.Affordance = raw
|
||||
}
|
||||
return assemble(ref.Service.Name, ref.ResourcePath, m)
|
||||
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
|
||||
}
|
||||
|
||||
// Envelopes renders the given method refs into envelopes, sorted by name. The
|
||||
@@ -249,7 +205,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
|
||||
|
||||
return Envelope{
|
||||
Name: name,
|
||||
Description: normalizeDesc(m.Description),
|
||||
Description: m.Description,
|
||||
InputSchema: buildInputSchema(m),
|
||||
OutputSchema: buildOutputSchema(m),
|
||||
Meta: buildMeta(m),
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -506,31 +504,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
|
||||
// service + method id), so a method whose metadata carries none still gets
|
||||
// guidance in its envelope when an overlay entry exists.
|
||||
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
|
||||
// The overlay source is the top-level affordance/ tree, injected at startup;
|
||||
// inject a fixture so this unit test does not depend on the shipped content.
|
||||
// Reset afterwards (this binary installs no source by default) for isolation.
|
||||
t.Cleanup(func() { affordance.SetSource(nil) })
|
||||
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
|
||||
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
|
||||
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
|
||||
if env.Meta == nil || env.Meta.Affordance == nil {
|
||||
t.Fatal("expected affordance from the approval overlay, got none")
|
||||
}
|
||||
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
|
||||
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
|
||||
}
|
||||
|
||||
// A method id with no overlay entry carries no affordance.
|
||||
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
|
||||
if bare.Meta != nil && bare.Meta.Affordance != nil {
|
||||
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
)
|
||||
|
||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||
//
|
||||
// The REST route (httpMethod/path) is deliberately NOT exposed: every
|
||||
// schema-resolvable method already has a typed command, so the raw path would
|
||||
// only tempt an agent toward the `api` escape hatch.
|
||||
type Envelope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
@@ -48,15 +44,9 @@ type OutputSchema struct {
|
||||
// "params" / "data" sub-objects inside inputSchema): it lists which keys
|
||||
// inside that object's Properties are mandatory. Leaf fields ignore it.
|
||||
type Property struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
|
||||
// absent on body/file fields, which travel via the section's Carrier.
|
||||
Flag string `json:"flag,omitempty"`
|
||||
// Carrier names the flag a whole inputSchema section travels on ("--data" /
|
||||
// "--file"); empty on the params section, whose properties carry their Flag.
|
||||
Carrier string `json:"carrier,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
|
||||
// each allowed value, in the same order. Omitted when no value carries a
|
||||
// description. This is the widely-recognized JSON-Schema extension (VS Code,
|
||||
|
||||
@@ -16,14 +16,6 @@ import (
|
||||
const (
|
||||
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
|
||||
EnvNoProxy = "LARK_CLI_NO_PROXY"
|
||||
|
||||
// EnvNoProxyWarn suppresses the proxy-detected warning when set to any
|
||||
// non-empty value, while leaving proxy behavior unchanged. Unlike
|
||||
// EnvNoProxy (which both silences the warning AND disables the proxy), this
|
||||
// keeps proxy egress active. It exists so agents consuming --format json can
|
||||
// keep using the proxy without the human-oriented warning line landing in
|
||||
// the output stream and breaking JSON parsing.
|
||||
EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN"
|
||||
)
|
||||
|
||||
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
|
||||
@@ -81,11 +73,6 @@ func redactProxyURL(raw string) string {
|
||||
// are redacted. Safe to call multiple times; only the first call prints.
|
||||
func WarnIfProxied(w io.Writer) {
|
||||
proxyWarningOnce.Do(func() {
|
||||
// Explicit opt-out: silence the warning without touching proxy behavior.
|
||||
// Checked before the plugin and env-proxy branches so it suppresses both.
|
||||
if os.Getenv(EnvNoProxyWarn) != "" {
|
||||
return
|
||||
}
|
||||
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
|
||||
// Shared), so its warning and disable instructions take precedence.
|
||||
// Emitting the env-proxy warning here would be misleading: it tells the
|
||||
@@ -101,7 +88,7 @@ func WarnIfProxied(w io.Writer) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n",
|
||||
key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn)
|
||||
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
|
||||
key, redactProxyURL(val), EnvNoProxy)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,47 +93,6 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN
|
||||
// suppresses the warning while the proxy stays configured (unlike
|
||||
// LARK_CLI_NO_PROXY, which also disables the proxy).
|
||||
func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
|
||||
t.Setenv(EnvNoProxyWarn, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that
|
||||
// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning.
|
||||
func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
proxyWarningOnce = sync.Once{}
|
||||
|
||||
old := proxyPluginStatus
|
||||
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
|
||||
t.Cleanup(func() { proxyPluginStatus = old })
|
||||
|
||||
t.Setenv(EnvNoProxyWarn, "1")
|
||||
|
||||
var buf bytes.Buffer
|
||||
WarnIfProxied(&buf)
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
|
||||
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.58",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, execFile } = require("child_process");
|
||||
|
||||
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
|
||||
// CommonJS script works on all supported Node versions (require() of an ESM
|
||||
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
|
||||
// point below before main() runs.
|
||||
let p;
|
||||
const p = require("@clack/prompts");
|
||||
|
||||
const PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
@@ -379,12 +374,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
p = await import("@clack/prompts");
|
||||
await main();
|
||||
})().catch((err) => {
|
||||
const msg = "Unexpected error: " + (err.message || err);
|
||||
if (p) p.cancel(msg);
|
||||
else console.error(msg);
|
||||
main().catch((err) => {
|
||||
p.cancel("Unexpected error: " + (err.message || err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "filter by target table"},
|
||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
|
||||
@@ -61,9 +61,6 @@ var AppsDBDataExport = common.Shortcut{
|
||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||
}
|
||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
|
||||
@@ -84,8 +84,8 @@ var AppsDBExecute = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
|
||||
}
|
||||
// 仅本地校验非空;不把文件内容写回公开的 --sql flag(避免 SQL 内容进入
|
||||
// flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
@@ -297,29 +297,10 @@ func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
// 不被写回公开的 --sql flag(避免泄露进 flag dump / 结构化日志)。优先 --sql(内联或 stdin,
|
||||
// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。
|
||||
func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) {
|
||||
if strings.TrimSpace(rctx.Str("sql")) != "" {
|
||||
return rctx.Str("sql"), nil
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if file == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(由 resolveExecuteSQL 在用时解析,--file 不入 flag)。
|
||||
// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。
|
||||
func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
sql, _ := resolveExecuteSQL(rctx)
|
||||
return map[string]interface{}{
|
||||
"sql": sql,
|
||||
"sql": rctx.Str("sql"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
@@ -165,7 +165,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
|
||||
@@ -41,9 +41,6 @@ var AppsFileDownload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
|
||||
@@ -39,8 +39,8 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "type", Desc: "filter by MIME type"},
|
||||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
|
||||
@@ -136,14 +136,7 @@ func putFileBytes(ctx context.Context, url string, content []byte, contentType,
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
// 用 mime.FormatMediaType 规范生成 Content-Disposition(自动按 RFC 2045 处理引号/转义),
|
||||
// 不手工拼接 header,杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName
|
||||
// 做 encodeURIComponent(控制字符/分隔符均 %XX 化),此处是第二道防线。
|
||||
disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)})
|
||||
if disposition == "" {
|
||||
disposition = "attachment"
|
||||
}
|
||||
req.Header.Set("Content-Disposition", disposition)
|
||||
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
@@ -177,11 +170,6 @@ func sanitizeUploadFileName(name string) string {
|
||||
if enc == "" {
|
||||
return "download_file"
|
||||
}
|
||||
// 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件,
|
||||
// 前置下划线消除隐藏文件语义。
|
||||
if strings.HasPrefix(enc, ".") {
|
||||
enc = "_" + enc
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -144,10 +143,8 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
||||
}
|
||||
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
||||
// 断言按解析结果(format-agnostic):mime.FormatMediaType 对无 tspecial 的名不加引号,
|
||||
// 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。
|
||||
if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" {
|
||||
t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err)
|
||||
if putCD != `attachment; filename="logo.png"` {
|
||||
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
||||
|
||||
@@ -46,118 +46,13 @@ func redactKeyInfo(info map[string]interface{}) map[string]interface{} {
|
||||
return out
|
||||
}
|
||||
|
||||
// allowedScopeAPIMethods is the HTTP method whitelist for --scope-api / request_scope.
|
||||
var allowedScopeAPIMethods = map[string]struct{}{
|
||||
"GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {},
|
||||
}
|
||||
|
||||
// validateScopeAPIMethod rejects methods outside the whitelist (e.g. TRACE, CONNECT, empty).
|
||||
func validateScopeAPIMethod(method string) error {
|
||||
if _, ok := allowedScopeAPIMethods[method]; !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http method %q not allowed; use one of GET, POST, PUT, PATCH, DELETE", method)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScopeAPIPath enforces basic openapi route hygiene as a first line of defense.
|
||||
func validateScopeAPIPath(p string) error {
|
||||
if p == "" || !strings.HasPrefix(p, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must start with '/', got %q", p)
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must not contain '..': %q", p)
|
||||
}
|
||||
if strings.Contains(p, "//") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"http path must not contain '//': %q", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequestScopeFields constrains a request_scope object to the documented
|
||||
// schema: only allow_all (bool) and http_infos ([{http_method, http_path}]). This
|
||||
// closes the raw --scope escape hatch from injecting undocumented fields.
|
||||
func validateRequestScopeFields(rs map[string]interface{}) error {
|
||||
for k := range rs {
|
||||
switch k {
|
||||
case "allow_all", "http_infos":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown field %q; only allow_all and http_infos are allowed", k)
|
||||
}
|
||||
}
|
||||
if v, ok := rs["allow_all"]; ok {
|
||||
if _, isBool := v.(bool); !isBool {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "allow_all must be a boolean")
|
||||
}
|
||||
}
|
||||
if v, ok := rs["http_infos"]; ok {
|
||||
arr, isArr := v.([]interface{})
|
||||
if !isArr {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "http_infos must be an array")
|
||||
}
|
||||
for _, item := range arr {
|
||||
m, isMap := item.(map[string]interface{})
|
||||
if !isMap {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "each http_infos entry must be an object")
|
||||
}
|
||||
for k := range m {
|
||||
switch k {
|
||||
case "http_method", "http_path":
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown field %q in http_infos entry; only http_method and http_path are allowed", k)
|
||||
}
|
||||
}
|
||||
method, _ := m["http_method"].(string)
|
||||
if err := validateScopeAPIMethod(method); err != nil {
|
||||
return err
|
||||
}
|
||||
path, _ := m["http_path"].(string)
|
||||
if err := validateScopeAPIPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRawScope parses a raw --scope JSON value: it must be an object that
|
||||
// conforms to the request_scope schema (validated by validateRequestScopeFields).
|
||||
func parseRawScope(scopeRaw string) (map[string]interface{}, error) {
|
||||
var rs interface{}
|
||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj, ok := rs.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope must be a JSON object")
|
||||
}
|
||||
if err := validateRequestScopeFields(obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case
|
||||
// httpInfo, validating the method against the whitelist and the path format.
|
||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo.
|
||||
func parseScopeAPI(s string) (map[string]interface{}, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(s))
|
||||
if len(fields) != 2 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s)
|
||||
}
|
||||
method := strings.ToUpper(fields[0])
|
||||
if err := validateScopeAPIMethod(method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := fields[1]
|
||||
if err := validateScopeAPIPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"http_method": method, "http_path": path}, nil
|
||||
return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil
|
||||
}
|
||||
|
||||
// buildRequestScope assembles config.request_scope (snake_case) from the scope flags.
|
||||
@@ -170,7 +65,11 @@ func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (inte
|
||||
if hasFriendly {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope")
|
||||
}
|
||||
return parseRawScope(scopeRaw)
|
||||
var rs interface{}
|
||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
if !hasFriendly {
|
||||
return nil, nil
|
||||
@@ -212,21 +111,18 @@ func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllow
|
||||
// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update).
|
||||
func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error {
|
||||
scopeRaw := strings.TrimSpace(rctx.Str("scope"))
|
||||
scopeAPIs := rctx.StrArray("scope-api")
|
||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(scopeAPIs) > 0) {
|
||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) {
|
||||
return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api").
|
||||
WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both")
|
||||
}
|
||||
if scopeRaw != "" {
|
||||
if _, err := parseRawScope(scopeRaw); err != nil {
|
||||
return appsValidationParamError("--scope", "invalid --scope: %s", err).
|
||||
WithHint("--scope takes a JSON object with only allow_all (bool) and http_infos ([{http_method, http_path}]); methods: GET, POST, PUT, PATCH, DELETE")
|
||||
}
|
||||
if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) {
|
||||
return appsValidationParamError("--scope", "--scope must be valid JSON").
|
||||
WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'")
|
||||
}
|
||||
for _, a := range scopeAPIs {
|
||||
if _, err := parseScopeAPI(a); err != nil {
|
||||
return appsValidationParamError("--scope-api", "invalid --scope-api: %s", err).
|
||||
WithHint("format: 'METHOD /openapi/path'; method one of GET, POST, PUT, PATCH, DELETE; path starts with '/', no '..' or '//'")
|
||||
for _, a := range rctx.StrArray("scope-api") {
|
||||
if len(strings.Fields(strings.TrimSpace(a))) != 2 {
|
||||
return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a).
|
||||
WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -78,108 +78,6 @@ func TestParseScopeAPI(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateScopeAPIMethod(t *testing.T) {
|
||||
for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
|
||||
if err := validateScopeAPIMethod(m); err != nil {
|
||||
t.Errorf("validateScopeAPIMethod(%q) = %v, want nil", m, err)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"TRACE", "CONNECT", "OPTIONS", "HEAD", "", "get"} {
|
||||
if err := validateScopeAPIMethod(m); err == nil {
|
||||
t.Errorf("validateScopeAPIMethod(%q) = nil, want error", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScopeAPIPath(t *testing.T) {
|
||||
for _, p := range []string{"/openapi/orders", "/openapi/v1/x"} {
|
||||
if err := validateScopeAPIPath(p); err != nil {
|
||||
t.Errorf("validateScopeAPIPath(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"", "openapi/x", "/openapi/../admin", "/..", "/openapi//x", "//x"} {
|
||||
if err := validateScopeAPIPath(p); err == nil {
|
||||
t.Errorf("validateScopeAPIPath(%q) = nil, want error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestScopeFields(t *testing.T) {
|
||||
ok := []map[string]interface{}{
|
||||
{"allow_all": true},
|
||||
{"allow_all": false, "http_infos": []interface{}{
|
||||
map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"},
|
||||
}},
|
||||
{},
|
||||
}
|
||||
for _, rs := range ok {
|
||||
if err := validateRequestScopeFields(rs); err != nil {
|
||||
t.Errorf("validateRequestScopeFields(%v) = %v, want nil", rs, err)
|
||||
}
|
||||
}
|
||||
bad := []map[string]interface{}{
|
||||
{"foo": 1}, // unknown top-level field
|
||||
{"allow_all": "yes"}, // wrong type
|
||||
{"http_infos": "x"}, // not an array
|
||||
{"http_infos": []interface{}{"x"}}, // entry not an object
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "TRACE", "http_path": "/x"}}}, // bad method
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "../x"}}}, // bad path
|
||||
{"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "/x", "extra": 1}}}, // unknown entry field
|
||||
}
|
||||
for _, rs := range bad {
|
||||
if err := validateRequestScopeFields(rs); err == nil {
|
||||
t.Errorf("validateRequestScopeFields(%v) = nil, want error", rs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRawScope(t *testing.T) {
|
||||
if _, err := parseRawScope(`{"allow_all":true}`); err != nil {
|
||||
t.Errorf("valid object errored: %v", err)
|
||||
}
|
||||
for _, raw := range []string{`["x"]`, `"s"`, `123`, `{"foo":1}`, `{bad`} {
|
||||
if _, err := parseRawScope(raw); err == nil {
|
||||
t.Errorf("parseRawScope(%q) = nil, want error", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScopeAPI_Rejects(t *testing.T) {
|
||||
bad := []string{"TRACE /openapi/x", "CONNECT /x", "GET ../admin", "GET openapi/x", "GET /a//b"}
|
||||
for _, in := range bad {
|
||||
if _, err := parseScopeAPI(in); err == nil {
|
||||
t.Errorf("parseScopeAPI(%q) = nil, want error", in)
|
||||
}
|
||||
}
|
||||
// regression: legitimate input still parses (and lowercases the method)
|
||||
info, err := parseScopeAPI("get /openapi/orders")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if info["http_method"] != "GET" || info["http_path"] != "/openapi/orders" {
|
||||
t.Errorf("info = %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestScope_RawValidation(t *testing.T) {
|
||||
// unknown field now rejected (HIGH-2)
|
||||
if _, err := buildRequestScope(false, nil, `{"foo":1}`); err == nil {
|
||||
t.Errorf("raw scope with unknown field must error")
|
||||
}
|
||||
// non-object rejected
|
||||
if _, err := buildRequestScope(false, nil, `["x"]`); err == nil {
|
||||
t.Errorf("non-object raw scope must error")
|
||||
}
|
||||
// nested bad method rejected
|
||||
if _, err := buildRequestScope(false, nil, `{"http_infos":[{"http_method":"TRACE","http_path":"/x"}]}`); err == nil {
|
||||
t.Errorf("raw scope with bad nested method must error")
|
||||
}
|
||||
// regression: documented fields pass
|
||||
if _, err := buildRequestScope(false, nil, `{"allow_all":true}`); err != nil {
|
||||
t.Errorf("valid raw scope errored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestScope(t *testing.T) {
|
||||
t.Run("nothing set -> nil", func(t *testing.T) {
|
||||
rs, err := buildRequestScope(false, nil, "")
|
||||
|
||||
@@ -34,10 +34,9 @@ var AppsPluginInstall = common.Shortcut{
|
||||
Scopes: []string{},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate (install or update to latest)",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0 (install or update to specific version)",
|
||||
"Example: lark-cli apps +plugin-install (batch install all declared plugins from package.json actionPlugins)",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0",
|
||||
"Example: lark-cli apps +plugin-install (install all declared plugins in package.json)",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
|
||||
|
||||
@@ -21,7 +21,6 @@ var AppsPluginList = common.Shortcut{
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-list",
|
||||
"Example: lark-cli apps +plugin-list --format pretty",
|
||||
},
|
||||
|
||||
@@ -22,7 +22,6 @@ var AppsPluginUninstall = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{},
|
||||
Tips: []string{
|
||||
"Run in project root (like npm); does NOT take --app-id",
|
||||
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRejectOutputTraversal pins HIGH-3: --output rejects absolute paths and
|
||||
// any .. traversal component; empty and ordinary relative paths pass.
|
||||
func TestRejectOutputTraversal(t *testing.T) {
|
||||
ok := []string{"", "out.csv", "./out.csv", "sub/dir/out.csv", "a..b.csv", "foo..bar/x.csv"}
|
||||
for _, p := range ok {
|
||||
if err := rejectOutputTraversal(p); err != nil {
|
||||
t.Errorf("rejectOutputTraversal(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
bad := []string{"/etc/passwd", "../x", "../../etc/cron.d/evil", "sub/../../x", "./../x"}
|
||||
for _, p := range bad {
|
||||
if err := rejectOutputTraversal(p); err == nil {
|
||||
t.Errorf("rejectOutputTraversal(%q) = nil, want validation error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName pins HIGH-4 / LOW-1: control & separator chars are
|
||||
// neutralized (percent-encoded, no raw CR/LF/TAB/NUL/quote) and the result never
|
||||
// starts with a dot (hidden-file overwrite guard).
|
||||
func TestSanitizeUploadFileName(t *testing.T) {
|
||||
// LOW-1: dotfiles get a leading underscore.
|
||||
for _, in := range []string{".bashrc", ".ssh", "..hidden"} {
|
||||
got := sanitizeUploadFileName(in)
|
||||
if strings.HasPrefix(got, ".") {
|
||||
t.Errorf("sanitizeUploadFileName(%q) = %q, must not start with '.'", in, got)
|
||||
}
|
||||
}
|
||||
// HIGH-4: header-breaking / control chars must not survive raw.
|
||||
raw := "a\r\nb\tc\x00d\"e.png"
|
||||
got := sanitizeUploadFileName(raw)
|
||||
for _, bad := range []string{"\r", "\n", "\t", "\x00", "\"", " "} {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("sanitizeUploadFileName(%q) = %q, still contains raw %q", raw, got, bad)
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("sanitizeUploadFileName returned empty for non-empty input")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -40,28 +39,3 @@ func withAppsHint(err error, hint string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||||
// --output path. The authoritative guard is the local FileIO layer
|
||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||||
// symlinks), so traversal is already blocked at write time; this gives an
|
||||
// earlier, clearer validation error and pins the contract in the command layer.
|
||||
// Empty (use server-derived default) passes through. Absolute paths and any
|
||||
// ".." path component are rejected.
|
||||
func rejectOutputTraversal(output string) error {
|
||||
o := strings.TrimSpace(output)
|
||||
if o == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(o) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output must be a relative path within the current directory, got %q", o).WithParam("--output")
|
||||
}
|
||||
for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) {
|
||||
if seg == ".." {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--output must not contain .. path traversal, got %q", o).WithParam("--output")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -488,7 +488,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
// handled locally.
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err))
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
detail := logIDDetail(resp)
|
||||
if resp == nil || len(resp.RawBody) == 0 {
|
||||
@@ -501,7 +501,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
|
||||
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
||||
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
||||
if cerr != nil {
|
||||
return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint))
|
||||
return nil, withAppsHint(cerr, gitCredentialIssueHint)
|
||||
}
|
||||
if data != nil {
|
||||
result = data
|
||||
@@ -536,7 +536,6 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
if message == "" {
|
||||
message = "Git credential API returned non-zero BaseResp status"
|
||||
}
|
||||
message = gitcred.RedactCredentialText(message)
|
||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
|
||||
if logID != "" {
|
||||
baseErr = baseErr.WithLogID(logID)
|
||||
@@ -546,17 +545,6 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactGitCredentialIssueError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = gitcred.RedactCredentialText(p.Message)
|
||||
p.Hint = gitcred.RedactCredentialText(p.Hint)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
|
||||
logID := logIDString(resp)
|
||||
if logID == "" {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1026,24 +1027,25 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseIssueCredentialDataRedactsCredentialErrorMessage verifies that the
|
||||
// git-credential boundary does not pass server-provided credential details into
|
||||
// the user-visible typed envelope message.
|
||||
func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
serverMsg := "permission denied: " +
|
||||
testCredentialAssignment("token", samplePAT) + " " +
|
||||
testCredentialAssignment("password", samplePassword) + " " +
|
||||
testCredentialURLWithUserInfo("example.com/repo.git", samplePAT)
|
||||
// TestParseIssueCredentialDataMessageAddsNoExtraSecret verifies the security
|
||||
// condition that apps does not ADDITIONALLY inject any token/secret into the
|
||||
// Git-credential error it builds. The server `msg` is passed through verbatim
|
||||
// into Problem.Message, and the only thing apps adds is the static
|
||||
// gitCredentialIssueHint — which itself contains no secret. We feed a benign
|
||||
// server msg and assert (a) Message equals that msg exactly, and (b) neither
|
||||
// Message nor Hint contains any token/secret-shaped string.
|
||||
//
|
||||
// Note: server msg passthrough is the shared classifier's responsibility;
|
||||
// apps adds only a static hint. There is no msg redaction in this path, so
|
||||
// this test does not assert a redaction that does not exist — it asserts
|
||||
// that apps injects nothing sensitive of its own.
|
||||
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
const serverMsg = "permission denied"
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resp *larkcore.ApiResp
|
||||
wantType errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantCode int
|
||||
name string
|
||||
resp *larkcore.ApiResp
|
||||
}{
|
||||
{
|
||||
name: "http error path",
|
||||
@@ -1052,9 +1054,6 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
|
||||
Header: header,
|
||||
},
|
||||
wantType: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
wantCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "business code path",
|
||||
@@ -1063,9 +1062,6 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
|
||||
Header: header,
|
||||
},
|
||||
wantType: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
wantCode: 999,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -1077,85 +1073,30 @@ func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != tc.wantType || p.Subtype != tc.wantSubtype || p.Code != tc.wantCode {
|
||||
t.Fatalf("problem metadata = %s/%s code=%d, want %s/%s code=%d",
|
||||
p.Category, p.Subtype, p.Code, tc.wantType, tc.wantSubtype, tc.wantCode)
|
||||
}
|
||||
if !strings.Contains(p.Message, "permission denied") {
|
||||
t.Fatalf("Message = %q, want it to retain non-secret server context", p.Message)
|
||||
// (a) The server msg survives into the message. The business-code
|
||||
// path passes it through verbatim; the HTTP-status path reports
|
||||
// "HTTP <status>: <body>" via the shared classifier, so assert
|
||||
// containment rather than equality.
|
||||
if !strings.Contains(p.Message, serverMsg) {
|
||||
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
|
||||
}
|
||||
// apps adds only the static hint — assert that exact static text,
|
||||
// proving apps injects no per-request secret into the hint either.
|
||||
if p.Hint != gitCredentialIssueHint {
|
||||
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
|
||||
}
|
||||
// (b) Neither field may contain a token/secret-shaped string that
|
||||
// apps could have added on top of the framework passthrough.
|
||||
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S|password\s*[=:]\s*\S)`)
|
||||
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
|
||||
for _, leaked := range []string{samplePAT, "user:" + samplePAT + "@", testCredentialAssignment("password", samplePassword)} {
|
||||
if strings.Contains(val, leaked) {
|
||||
t.Fatalf("%s leaks %q: %q", field, leaked, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
testRedactedAssignment("token"),
|
||||
testRedactedAssignment("password"),
|
||||
"https://***@example.com/repo.git",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Fatalf("Message missing %q after redaction: %q", want, p.Message)
|
||||
if secret.MatchString(val) {
|
||||
t.Fatalf("%s leaks a token/secret-shaped string: %q", field, val)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIssueCredentialDataRedactsSDKErrorPreservesCause(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
cause := errors.New("transport failed with " + testCredentialAssignment("token", samplePAT))
|
||||
|
||||
_, err := parseIssueCredentialData(nil, cause, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected SDK-boundary error, got nil")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("error does not preserve cause: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem metadata = %s/%s, want %s/%s",
|
||||
p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if strings.Contains(p.Message, samplePAT) {
|
||||
t.Fatalf("message leaks credential value: %q", p.Message)
|
||||
}
|
||||
if want := testRedactedAssignment("token"); !strings.Contains(p.Message, want) {
|
||||
t.Fatalf("message missing %q after redaction: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactGitCredentialIssueErrorNil(t *testing.T) {
|
||||
if err := redactGitCredentialIssueError(nil); err != nil {
|
||||
t.Fatalf("redactGitCredentialIssueError(nil) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicSafeJoin(parts ...string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func testCredentialAssignment(key, value string) string {
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func testRedactedAssignment(key string) string {
|
||||
return key + "=<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
||||
return "https://" + "user:" + credential + "@" + hostPath
|
||||
}
|
||||
|
||||
type errorReader struct{}
|
||||
|
||||
func (errorReader) Read(p []byte) (int, error) {
|
||||
|
||||
@@ -542,15 +542,7 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
||||
if err := manager.Store.Upsert(*record); err != nil {
|
||||
t.Fatalf("Upsert expired record returned error: %v", err)
|
||||
}
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
issuer.err = errs.NewAPIError(
|
||||
errs.SubtypeUnknown,
|
||||
"permission denied: "+
|
||||
testCredentialAssignment("token", samplePAT)+" "+
|
||||
testCredentialAssignment("password", samplePassword)+" "+
|
||||
testCredentialURLWithUserInfo("example.com/git/u/app.git", samplePAT),
|
||||
).WithHint("retry without " + testCredentialAssignment("token", samplePAT)).WithLogID("log_x")
|
||||
issuer.err = errors.New("permission denied")
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
@@ -560,22 +552,6 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", out.String())
|
||||
}
|
||||
stderr := errOut.String()
|
||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("password", samplePassword), "user:" + samplePAT + "@"} {
|
||||
if strings.Contains(stderr, leaked) {
|
||||
t.Fatalf("stderr leaks %q: %s", leaked, stderr)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
testRedactedAssignment("token"),
|
||||
testRedactedAssignment("password"),
|
||||
"https://***@example.com/git/u/app.git",
|
||||
"log_id=log_x",
|
||||
} {
|
||||
if !strings.Contains(stderr, want) {
|
||||
t.Fatalf("stderr missing %q in %s", want, stderr)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
|
||||
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
|
||||
}
|
||||
@@ -1435,36 +1411,10 @@ func TestSecretStoreBranches(t *testing.T) {
|
||||
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
|
||||
t.Fatal("SecretStore.Set empty ref returned nil error")
|
||||
}
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
kc.setErr = errors.New("keychain set failed with " + testCredentialAssignment("token", samplePAT))
|
||||
var setCfgErr *errs.ConfigError
|
||||
setErr := NewSecretStore(kc).Set("ref", samplePAT)
|
||||
if setErr == nil || !errors.As(setErr, &setCfgErr) {
|
||||
t.Fatalf("SecretStore.Set keychain error = %T %v, want ConfigError", setErr, setErr)
|
||||
}
|
||||
assertProblem(t, setErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
||||
if setCfgErr.Message != "save local Git credential PAT to keychain failed" {
|
||||
t.Fatalf("ConfigError message = %q, want static keychain failure", setCfgErr.Message)
|
||||
}
|
||||
if strings.Contains(setCfgErr.Message, samplePAT) {
|
||||
t.Fatalf("ConfigError message leaks credential value: %q", setCfgErr.Message)
|
||||
}
|
||||
if !errors.Is(setCfgErr, kc.setErr) {
|
||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
||||
}
|
||||
kc.setErr = nil
|
||||
kc.removeErr = errors.New("keychain remove failed")
|
||||
var cfgErr *errs.ConfigError
|
||||
removeErr := NewSecretStore(kc).Remove("ref")
|
||||
if removeErr == nil || !errors.As(removeErr, &cfgErr) {
|
||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", removeErr, removeErr)
|
||||
}
|
||||
assertProblem(t, removeErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
||||
if cfgErr.Message != "remove local Git credential PAT from keychain failed" {
|
||||
t.Fatalf("ConfigError message = %q, want static keychain failure", cfgErr.Message)
|
||||
}
|
||||
if !errors.Is(cfgErr, kc.removeErr) {
|
||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
||||
if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1546,56 +1496,6 @@ func TestLockAppHeldTimesOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetPreservesTypedLockAppError(t *testing.T) {
|
||||
now := time.Unix(1780000000, 0)
|
||||
store := NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename))
|
||||
kc := newFakeKeychain()
|
||||
record := CredentialRecord{
|
||||
AppID: "app_xxx",
|
||||
GitHTTPURL: "https://example.com/git/u/app.git",
|
||||
Profile: testProfile().Profile,
|
||||
ProfileAppID: testProfile().ProfileAppID,
|
||||
UserOpenID: testProfile().UserOpenID,
|
||||
Username: "x-access-token",
|
||||
PATRef: "ref",
|
||||
Status: StatusConfirmed,
|
||||
ExpiresAt: now.Add(-time.Minute).Unix(),
|
||||
UpdatedAt: now.Unix(),
|
||||
}
|
||||
if err := store.Upsert(record); err != nil {
|
||||
t.Fatalf("Upsert returned error: %v", err)
|
||||
}
|
||||
kc.values[record.PATRef] = "old-pat"
|
||||
|
||||
blocker := filepath.Join(t.TempDir(), "config-blocker")
|
||||
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
|
||||
t.Fatalf("write config blocker: %v", err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
|
||||
|
||||
manager := NewManager(store, NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
|
||||
GitHTTPURL: record.GitHTTPURL,
|
||||
PAT: "new-pat",
|
||||
ExpiresAt: now.Add(24 * time.Hour).Unix(),
|
||||
}})
|
||||
manager.Now = func() time.Time { return now }
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", out.String())
|
||||
}
|
||||
stderr := errOut.String()
|
||||
if !strings.Contains(stderr, "create Git credential lock dir") {
|
||||
t.Fatalf("stderr = %q, want typed lock-dir setup error", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "acquire Git credential lock") {
|
||||
t.Fatalf("stderr rewrapped typed lock error: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
|
||||
now := time.Unix(1780000000, 0)
|
||||
path := filepath.Join(t.TempDir(), MetadataFilename)
|
||||
@@ -1871,15 +1771,8 @@ func TestManagerGetBranches(t *testing.T) {
|
||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||
t.Fatalf("Get keychain set error returned error: %v", err)
|
||||
}
|
||||
stderr := errOut.String()
|
||||
if !strings.Contains(stderr, "save local Git credential PAT to keychain failed") {
|
||||
t.Fatalf("stderr = %q, want static keychain error", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli apps +git-credential-init") {
|
||||
t.Fatalf("stderr = %q, want init retry hint", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "keychain locked") {
|
||||
t.Fatalf("stderr leaks keychain cause: %q", stderr)
|
||||
if !strings.Contains(errOut.String(), "keychain locked") {
|
||||
t.Fatalf("stderr = %q, want keychain error", errOut.String())
|
||||
}
|
||||
|
||||
kc.setErr = nil
|
||||
@@ -2272,189 +2165,6 @@ func TestNilManagerUsesTimeNow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactCredentialText focuses on the redaction regex, asserting it
|
||||
// covers credential shapes across forms and does not over-match concatenated
|
||||
// words. JSON-quoted forms are common in server-provided error bodies and must
|
||||
// be covered; concatenated words like mytoken must not be treated as token.
|
||||
func TestRedactCredentialText(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
sampleSecret := "sample-secret"
|
||||
githubLikeToken := testPublicSafeJoin("gh", "p_") + strings.Repeat("x", 20)
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "bare assignment",
|
||||
in: "permission denied: " +
|
||||
testCredentialAssignment("token", samplePAT) + " " +
|
||||
testCredentialAssignment("password", samplePassword),
|
||||
want: "permission denied: " +
|
||||
testRedactedAssignment("token") + " " +
|
||||
testRedactedAssignment("password"),
|
||||
},
|
||||
{
|
||||
name: "json double-quoted value",
|
||||
in: "body={" +
|
||||
testDoubleQuotedAssignment("password", samplePassword) + "," +
|
||||
testDoubleQuotedAssignment("token", samplePAT) +
|
||||
"}",
|
||||
want: "body={" +
|
||||
testDoubleQuotedRedactedAssignment("password") + "," +
|
||||
testDoubleQuotedRedactedAssignment("token") +
|
||||
"}",
|
||||
},
|
||||
{
|
||||
name: "json single-quoted value",
|
||||
in: "body={" + testSingleQuotedAssignment("secret", sampleSecret) + "}",
|
||||
want: "body={" + testSingleQuotedRedactedAssignment("secret") + "}",
|
||||
},
|
||||
{
|
||||
name: "colon separator with quoted value",
|
||||
in: testCredentialColon("token", `"`+samplePAT+`"`),
|
||||
want: testRedactedColon("token"),
|
||||
},
|
||||
{
|
||||
name: "url userinfo",
|
||||
in: "clone " + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT),
|
||||
want: "clone https://***@example.com/repo.git",
|
||||
},
|
||||
{
|
||||
name: "bearer header",
|
||||
in: testAuthorizationBearer(githubLikeToken),
|
||||
want: testRedactedAuthorizationBearer(),
|
||||
},
|
||||
{
|
||||
name: "pat-like standalone",
|
||||
in: "issued " + samplePAT + " for app",
|
||||
want: "issued <redacted> for app",
|
||||
},
|
||||
{
|
||||
name: "concatenated key not redacted",
|
||||
in: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
||||
want: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactCredentialText(tc.in); got != tc.want {
|
||||
t.Fatalf("RedactCredentialText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeCredentialErrorMessageFallbacks(t *testing.T) {
|
||||
if got := safeCredentialErrorMessage(nil); got != "" {
|
||||
t.Fatalf("safeCredentialErrorMessage(nil) = %q, want empty", got)
|
||||
}
|
||||
|
||||
if got := safeCredentialErrorMessage(&errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
}}); got != "config/invalid_config" {
|
||||
t.Fatalf("safeCredentialErrorMessage typed fallback = %q, want config/invalid_config", got)
|
||||
}
|
||||
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
got := safeCredentialErrorMessage(errors.New("transport failed with " + testCredentialAssignment("token", samplePAT)))
|
||||
if strings.Contains(got, samplePAT) {
|
||||
t.Fatalf("safeCredentialErrorMessage leaks credential value: %q", got)
|
||||
}
|
||||
if want := testRedactedAssignment("token"); !strings.Contains(got, want) {
|
||||
t.Fatalf("safeCredentialErrorMessage missing %q after redaction: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCredentialErrorRedactsTypedMessageHint(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
err := errs.NewInternalError(errs.SubtypeStorage, "save failed with %s", testCredentialAssignment("token", samplePAT)).
|
||||
WithHint("retry without %s", testCredentialAssignment("password", samplePAT)).
|
||||
WithLogID("log_x")
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeCredentialError(&buf, "Git credential refresh failed", err)
|
||||
got := buf.String()
|
||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("token", samplePAT), testCredentialAssignment("password", samplePAT)} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("writeCredentialError leaks credential value %q in %q", leaked, got)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Git credential refresh failed: save failed with " + testRedactedAssignment("token"),
|
||||
"log_id=log_x",
|
||||
"hint: retry without " + testRedactedAssignment("password"),
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("writeCredentialError output missing %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
writeCredentialError(nil, "ignored", err)
|
||||
writeCredentialError(&buf, "ignored", nil)
|
||||
}
|
||||
|
||||
func assertProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory || p.Subtype != wantSubtype {
|
||||
t.Fatalf("problem metadata = %s/%s, want %s/%s", p.Category, p.Subtype, wantCategory, wantSubtype)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicSafeJoin(parts ...string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func testCredentialAssignment(key, value string) string {
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func testRedactedAssignment(key string) string {
|
||||
return key + "=<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialColon(key, value string) string {
|
||||
return key + ": " + value
|
||||
}
|
||||
|
||||
func testRedactedColon(key string) string {
|
||||
return key + ": <redacted>"
|
||||
}
|
||||
|
||||
func testDoubleQuotedAssignment(key, value string) string {
|
||||
return `"` + key + `"` + ":" + `"` + value + `"`
|
||||
}
|
||||
|
||||
func testDoubleQuotedRedactedAssignment(key string) string {
|
||||
return `"` + key + `"` + ":<redacted>"
|
||||
}
|
||||
|
||||
func testSingleQuotedAssignment(key, value string) string {
|
||||
return `'` + key + `'` + ":" + `'` + value + `'`
|
||||
}
|
||||
|
||||
func testSingleQuotedRedactedAssignment(key string) string {
|
||||
return `'` + key + `'` + ":<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
||||
return "https://" + "user:" + credential + "@" + hostPath
|
||||
}
|
||||
|
||||
func testAuthorizationBearer(value string) string {
|
||||
return "Authorization" + ": " + "Bearer " + value
|
||||
}
|
||||
|
||||
func testRedactedAuthorizationBearer() string {
|
||||
return "Authorization" + ": " + "Bearer <redacted>"
|
||||
}
|
||||
|
||||
func testProfile() ProfileContext {
|
||||
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,25 +27,6 @@ type Manager struct {
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// credentialKeys is the shared list of credential field names to redact; the
|
||||
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
|
||||
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
|
||||
|
||||
var (
|
||||
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
||||
// credentialAssignmentRE matches credential key assignments, including JSON
|
||||
// quoted forms. Capture group 1 is the key and separator; only the value is
|
||||
// replaced with <redacted>. The key is one of three forms — double-quoted,
|
||||
// single-quoted, or bare with a word boundary — so concatenated words like
|
||||
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
|
||||
// alternation does not bind the quote/boundary to only the first and last key.
|
||||
credentialAssignmentRE = regexp.MustCompile(
|
||||
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
|
||||
)
|
||||
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
|
||||
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
|
||||
)
|
||||
|
||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||
return &Manager{
|
||||
Store: store,
|
||||
@@ -192,12 +172,12 @@ func (m *Manager) List() (*ListResult, error) {
|
||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
record, pat, ok, err := m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -207,28 +187,18 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
|
||||
// Lock ordering convention (see lock.go package comment): always acquire
|
||||
// lockApp before lockURL. lockApp is a cross-process file lock with a
|
||||
// timeout and possible setup failure; acquiring it first avoids holding an
|
||||
// in-process mutex on the failure path, which would risk a deadlock.
|
||||
unlock := lockURL(url)
|
||||
defer unlock()
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
// lockApp may already return a typed error, for example when creating
|
||||
// the lock directory fails. Preserve those classifications and only wrap
|
||||
// raw lockfile errors to add app context.
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
|
||||
return nil
|
||||
}
|
||||
defer unlockApp()
|
||||
unlockURL := lockURL(url)
|
||||
defer unlockURL()
|
||||
|
||||
record, pat, ok, err = m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -243,17 +213,16 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
|
||||
return nil
|
||||
}
|
||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if urlErr != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
|
||||
return nil
|
||||
}
|
||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if issuedURL != url {
|
||||
@@ -263,7 +232,7 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
if issued.ExpiresAt < record.ExpiresAt {
|
||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||
if readErr != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", readErr)
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
|
||||
return nil
|
||||
}
|
||||
if found && m.usable(latest, latestPAT) {
|
||||
@@ -278,64 +247,17 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
record.Status = StatusConfirmed
|
||||
oldPAT := pat
|
||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
return writeGitCredential(out, record.Username, issued.PAT)
|
||||
}
|
||||
|
||||
func writeCredentialError(w io.Writer, prefix string, err error) {
|
||||
if w == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
|
||||
}
|
||||
|
||||
func safeCredentialErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
message := RedactCredentialText(p.Message)
|
||||
if p.LogID != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "log_id=" + p.LogID
|
||||
}
|
||||
if p.Hint != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "hint: " + RedactCredentialText(p.Hint)
|
||||
}
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
if p.Category != "" || p.Subtype != "" {
|
||||
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
|
||||
}
|
||||
}
|
||||
return RedactCredentialText(err.Error())
|
||||
}
|
||||
|
||||
// RedactCredentialText masks credential fragments that may appear in free
|
||||
// text, covering URL userinfo, Authorization bearer headers, credential
|
||||
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
|
||||
// the gitcred and apps packages so the redaction logic does not fork.
|
||||
func RedactCredentialText(text string) string {
|
||||
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
|
||||
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
|
||||
return text
|
||||
}
|
||||
|
||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil || len(records) == 0 {
|
||||
|
||||
@@ -42,15 +42,7 @@ func (s *SecretStore) Set(ref, pat string) error {
|
||||
Message: "keychain PAT reference is empty",
|
||||
}}
|
||||
}
|
||||
if err := s.kc.Set(KeychainService, ref, pat); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "save local Git credential PAT to keychain failed",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
return s.kc.Set(KeychainService, ref, pat)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Remove(ref string) error {
|
||||
@@ -72,7 +64,7 @@ func (s *SecretStore) Remove(ref string) error {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "remove local Git credential PAT from keychain failed",
|
||||
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}, Cause: err}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitcred manages the lifecycle of app Git credentials.
|
||||
//
|
||||
// Lock ordering convention — read this before adding any new lock acquisition:
|
||||
//
|
||||
// ALWAYS acquire lockApp BEFORE lockURL. Never invert this order.
|
||||
//
|
||||
// Rationale:
|
||||
// - lockApp is a cross-process file lock with bounded timeout (2s) and a
|
||||
// possible setup error; acquiring it first keeps the failure surface
|
||||
// outside any in-process lock and avoids holding the in-process mutex
|
||||
// while waiting on I/O / another process.
|
||||
// - lockURL is an in-process sync.Mutex that never fails and blocks
|
||||
// indefinitely; holding it while waiting on lockApp would risk
|
||||
// deadlocking with a concurrent goroutine that held lockApp first.
|
||||
//
|
||||
// Paths that only manipulate per-app state (Init, Remove, Erase) only need
|
||||
// lockApp. Get() is the only path that touches per-URL state in addition to
|
||||
// per-app state, so it is the only caller that takes both locks.
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
@@ -38,11 +20,6 @@ var urlLocks sync.Map
|
||||
|
||||
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// lockURL acquires an in-process, per-URL mutex. It never returns an error
|
||||
// and blocks until the mutex is available.
|
||||
//
|
||||
// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package
|
||||
// comment for the full convention.
|
||||
func lockURL(url string) func() {
|
||||
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
|
||||
mu := actual.(*sync.Mutex)
|
||||
@@ -50,12 +27,6 @@ func lockURL(url string) func() {
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// lockApp acquires a cross-process file lock scoped to the given appID. It
|
||||
// returns an unlock function or an error if the lock directory cannot be
|
||||
// created or the lock cannot be acquired within the 2s timeout.
|
||||
//
|
||||
// Lock ordering: when both lockApp and lockURL are needed, lockApp must be
|
||||
// taken FIRST. See package comment for the full convention.
|
||||
func lockApp(appID string) (func(), error) {
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
@@ -90,8 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
"extra_param": docsFetchExtraParam,
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -488,44 +487,6 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
extraParam, ok := body["extra_param"].(string)
|
||||
if !ok || extraParam == "" {
|
||||
t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"])
|
||||
}
|
||||
var got map[string]bool
|
||||
if err := json.Unmarshal([]byte(extraParam), &got); err != nil {
|
||||
t.Fatalf("decode extra_param %q: %v", extraParam, err)
|
||||
}
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, flag := range v2FetchFlags() {
|
||||
if flag.Name == "reference-map" {
|
||||
t.Fatal("fetch should not expose reference-map flag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -844,48 +805,20 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
|
||||
cmd.Flags().String("detail", fetchDefault("detail"), "")
|
||||
cmd.Flags().String("lang", fetchDefault("lang"), "")
|
||||
cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "")
|
||||
cmd.Flags().String("scope", fetchDefault("scope"), "")
|
||||
cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "")
|
||||
cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "")
|
||||
cmd.Flags().String("keyword", fetchDefault("keyword"), "")
|
||||
cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "")
|
||||
cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "")
|
||||
cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
// fetchDefault returns the declared default for a flag from the real
|
||||
// v2FetchFlags definition so tests don't hardcode a stale default.
|
||||
// It panics if the flag is not found, since a missing flag indicates
|
||||
// a test setup error rather than a runtime condition.
|
||||
func fetchDefault(name string) string {
|
||||
for _, fl := range v2FetchFlags() {
|
||||
if fl.Name == name {
|
||||
return fl.Default
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("fetchDefault: flag %q not found in v2FetchFlags", name))
|
||||
}
|
||||
|
||||
// fetchDefaultInt returns the declared default for an int flag from
|
||||
// v2FetchFlags, parsed as an int. It panics if the flag is not found
|
||||
// or its default cannot be parsed as an int.
|
||||
func fetchDefaultInt(name string) int {
|
||||
s := fetchDefault(name)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
var d int
|
||||
if _, err := fmt.Sscanf(s, "%d", &d); err != nil {
|
||||
panic(fmt.Sprintf("fetchDefaultInt: flag %q default %q is not an int", name, s))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -900,17 +833,17 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("api-version", "", "")
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
|
||||
cmd.Flags().String("detail", fetchDefault("detail"), "")
|
||||
cmd.Flags().String("lang", fetchDefault("lang"), "")
|
||||
cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "")
|
||||
cmd.Flags().String("scope", fetchDefault("scope"), "")
|
||||
cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "")
|
||||
cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "")
|
||||
cmd.Flags().String("keyword", fetchDefault("keyword"), "")
|
||||
cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "")
|
||||
cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "")
|
||||
cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
cmd.Flags().String("end-block-id", "", "")
|
||||
cmd.Flags().String("keyword", "", "")
|
||||
cmd.Flags().Int("context-before", 0, "")
|
||||
cmd.Flags().Int("context-after", 0, "")
|
||||
cmd.Flags().Int("max-depth", -1, "")
|
||||
cmd.Flags().String("offset", "", "")
|
||||
cmd.Flags().String("limit", "", "")
|
||||
if apiVersion != "" {
|
||||
@@ -942,7 +875,6 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("reference-map", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
|
||||
@@ -4,11 +4,9 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -63,116 +61,6 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag common.Flag
|
||||
for _, candidate := range v2UpdateFlags() {
|
||||
if candidate.Name == "reference-map" {
|
||||
flag = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if flag.Name == "" {
|
||||
t.Fatal("reference-map flag not found")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("reference-map flag should be public")
|
||||
}
|
||||
if flag.Type != "" {
|
||||
t.Fatalf("reference-map flag Type = %q, want default string", flag.Type)
|
||||
}
|
||||
if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if flag.Desc != docsUpdateReferenceMapFlagDesc {
|
||||
t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"command": "append",
|
||||
"content": `<p><widget data-ref="r1"></widget></p>`,
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
})
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
refMap, ok := body["reference_map"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
|
||||
}
|
||||
widget, _ := refMap["widget"].(map[string]interface{})
|
||||
r1, _ := widget["r1"].(map[string]interface{})
|
||||
if got := r1["label"]; got != "widget-ref-value" {
|
||||
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
|
||||
}
|
||||
if got, want := body["command"], "block_insert_after"; got != want {
|
||||
t.Fatalf("command = %#v, want %q", got, want)
|
||||
}
|
||||
if got, want := body["block_id"], "-1"; got != want {
|
||||
t.Fatalf("block_id = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "invalid json",
|
||||
setFlags: map[string]string{
|
||||
"reference-map": "{",
|
||||
},
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
setFlags: map[string]string{
|
||||
"reference-map": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "without content",
|
||||
setFlags: map[string]string{
|
||||
"content": "",
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported command",
|
||||
setFlags: map[string]string{
|
||||
"command": "block_move_after",
|
||||
"block-id": "blk_anchor",
|
||||
"src-block-ids": "blk_src",
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateUpdateV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateUpdateV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map")
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatal("validateUpdateV2() error lost underlying JSON cause")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -215,15 +103,6 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func hasUpdateTestInput(flag common.Flag, input string) bool {
|
||||
for _, candidate := range flag.Input {
|
||||
if candidate == input {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -234,7 +113,6 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("reference-map", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
|
||||
@@ -5,9 +5,7 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -24,15 +22,12 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
@@ -59,9 +54,6 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
|
||||
}
|
||||
content := runtime.Str("content")
|
||||
if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil {
|
||||
return err
|
||||
}
|
||||
pattern := runtime.Str("pattern")
|
||||
blockID := runtime.Str("block-id")
|
||||
srcBlockIDs := runtime.Str("src-block-ids")
|
||||
@@ -121,7 +113,7 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
body := buildUpdateBody(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
@@ -134,10 +126,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body, err := buildUpdateBodyWithReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
data, err := doDocAPI(runtime, "PUT", apiPath, body)
|
||||
if err != nil {
|
||||
@@ -149,24 +138,6 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
return body
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildUpdateBodyBase(runtime)
|
||||
if !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
}
|
||||
refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
body["reference_map"] = refMap
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cmd := runtime.Str("command")
|
||||
|
||||
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
|
||||
@@ -198,40 +169,3 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error {
|
||||
if !runtime.Changed("reference-map") {
|
||||
return nil
|
||||
}
|
||||
if !updateCommandAcceptsReferenceMap(command) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map")
|
||||
}
|
||||
if content == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map")
|
||||
}
|
||||
_, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
|
||||
return err
|
||||
}
|
||||
|
||||
func updateCommandAcceptsReferenceMap(command string) bool {
|
||||
switch command {
|
||||
case "str_replace", "block_insert_after", "block_replace", "overwrite", "append":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map")
|
||||
}
|
||||
var refMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err)
|
||||
}
|
||||
if refMap == nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map")
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
@@ -162,9 +162,6 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
@@ -216,9 +213,6 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
@@ -289,9 +283,6 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
if !strings.Contains(out, `"output_dir": "./exports"`) {
|
||||
t.Fatalf("stdout missing output_dir metadata: %s", out)
|
||||
}
|
||||
if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) {
|
||||
t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -342,9 +333,6 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -15,25 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
)
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping the
|
||||
// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
const sheetImageParentType = "sheet_image"
|
||||
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
@@ -68,7 +49,7 @@ var SheetMediaUpload = common.Shortcut{
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetMediaParentType(parentNode),
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
@@ -90,7 +71,7 @@ var SheetMediaUpload = common.Shortcut{
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetMediaParentType(parentNode),
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
@@ -160,14 +141,13 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro
|
||||
}
|
||||
|
||||
func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) {
|
||||
parentType := sheetMediaParentType(parentNode)
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
pn := parentNode
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: parentType,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
@@ -175,7 +155,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: parentType,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,39 +91,6 @@ func TestSheetMediaUploadDryRunSmallFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadDryRunSmallFileOfficeParentType pins the small-file
|
||||
// upload_all dry-run preview to the token-derived parent_type so the preview
|
||||
// agents/users will copy matches what Execute actually sends. Without this the
|
||||
// multipart dry-run branch could drift back to a hard-coded "sheet_image".
|
||||
func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"office_sheet_file"`) {
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
@@ -238,47 +205,6 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image.
|
||||
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
const officeToken = "fake_office_abc123"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
"--file", "img.png",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeSheetsMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != officeSheetFileParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, officeSheetFileParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != officeToken {
|
||||
t.Fatalf("parent_node = %q, want %q", got, officeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetMediaUploadFileNotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSheetsTestWorkingDir(t, dir)
|
||||
|
||||
@@ -50,42 +50,6 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" and the backend requires
|
||||
// "office_sheet_file" instead.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
fakeOfficeTokenPrefix = "fake_office_"
|
||||
)
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
// uploadSheetImage uploads a local image file as a spreadsheet media asset and
|
||||
// returns its file_token. It funnels every sheets image upload through one
|
||||
// place so the parent_type selection (see sheetMediaParentType) is never
|
||||
// duplicated or forgotten at a call site. Callers are expected to have already
|
||||
// resolved spreadsheetToken (the upload's parent_node) and stat'd the file.
|
||||
func uploadSheetImage(runtime *common.RuntimeContext, spreadsheetToken, filePath, fileName string, fileSize int64) (string, error) {
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetMediaParentType(spreadsheetToken),
|
||||
ParentNode: &spreadsheetToken,
|
||||
})
|
||||
}
|
||||
|
||||
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
|
||||
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
|
||||
// wiki node that must be resolved to its backing spreadsheet at Execute time.
|
||||
|
||||
@@ -861,10 +861,10 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
|
||||
manageBody, _ := buildToolBody("manage_float_image_object", input)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||||
Desc("upload local image to drive (parent_type=sheet_image)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": floatImageName(runtime),
|
||||
"parent_type": sheetMediaParentType(token),
|
||||
"parent_type": "sheet_image",
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + img,
|
||||
@@ -918,7 +918,13 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
|
||||
if err != nil {
|
||||
return "", sheetsInputStatError("image", err)
|
||||
}
|
||||
return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size())
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: img,
|
||||
FileName: floatImageName(runtime),
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &spreadsheetToken,
|
||||
})
|
||||
}
|
||||
|
||||
func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) {
|
||||
|
||||
@@ -791,10 +791,10 @@ var CellsSetImage = common.Shortcut{
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||||
Desc("upload local image to drive (parent_type=sheet_image)").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetMediaParentType(token),
|
||||
"parent_type": "sheet_image",
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + imgPath,
|
||||
@@ -832,7 +832,13 @@ var CellsSetImage = common.Shortcut{
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
}
|
||||
fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size())
|
||||
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: imgPath,
|
||||
FileName: fileName,
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -496,31 +496,6 @@ func TestCellsSetImage_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRunOfficeParentType confirms that an imported "office"
|
||||
// spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image, and that the
|
||||
// preview's parent_node carries the same token.
|
||||
func TestCellsSetImage_DryRunOfficeParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
const officeToken = "fake_office_abc123"
|
||||
calls := parseDryRunAPI(t, CellsSetImage, []string{
|
||||
"--spreadsheet-token", officeToken, "--sheet-id", testSheetID,
|
||||
"--range", "A1",
|
||||
"--image", "./README.md", // any existing-shaped path; dry-run skips stat
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
|
||||
}
|
||||
upload := calls[0].(map[string]interface{})
|
||||
ubody, _ := upload["body"].(map[string]interface{})
|
||||
if ubody["parent_type"] != officeSheetFileParentType {
|
||||
t.Errorf("parent_type = %v, want %s", ubody["parent_type"], officeSheetFileParentType)
|
||||
}
|
||||
if ubody["parent_node"] != officeToken {
|
||||
t.Errorf("parent_node = %v, want %s", ubody["parent_node"], officeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"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/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_"
|
||||
// synthetic token and must upload with "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
want string
|
||||
}{
|
||||
{"native spreadsheet token", "shtcnABC123", sheetImageParentType},
|
||||
{"empty token", "", sheetImageParentType},
|
||||
{"office imported token", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType},
|
||||
{"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sheetMediaParentType(tc.token); got != tc.want {
|
||||
t.Fatalf("sheetMediaParentType(%q) = %q, want %q", tc.token, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadSheetImage_ParentType exercises the uploadSheetImage collector end
|
||||
// to end (the Execute path the dry-run tests don't reach), asserting the
|
||||
// parent_type that actually goes out on the wire is derived from the token: a
|
||||
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
token string
|
||||
wantParentType string
|
||||
}{
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime, reg := newSheetMediaTestRuntime(t)
|
||||
// UploadDriveMediaAllTyped opens the file via the runtime's FileIO,
|
||||
// which sandboxes paths to the current working directory; chdir to a
|
||||
// temp dir and pass a relative name so the open is allowed.
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "boxTOK123"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
fileToken, err := uploadSheetImage(runtime, tc.token, "img.png", "img.png", 9)
|
||||
if err != nil {
|
||||
t.Fatalf("uploadSheetImage() error: %v", err)
|
||||
}
|
||||
if fileToken != "boxTOK123" {
|
||||
t.Fatalf("file_token = %q, want boxTOK123", fileToken)
|
||||
}
|
||||
|
||||
body := decodeSheetMediaMultipartBody(t, stub)
|
||||
if got := body.Fields["parent_type"]; got != tc.wantParentType {
|
||||
t.Fatalf("parent_type = %q, want %q", got, tc.wantParentType)
|
||||
}
|
||||
if got := body.Fields["parent_node"]; got != tc.token {
|
||||
t.Fatalf("parent_node = %q, want %q", got, tc.token)
|
||||
}
|
||||
if got := body.Fields["file_name"]; got != "img.png" {
|
||||
t.Fatalf("file_name = %q, want img.png", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadSheetImage_FileOpenError confirms a missing image surfaces as a
|
||||
// typed validation error (category=validation, subtype=invalid_argument) with
|
||||
// the original os-level cause preserved for errors.Is, and proves the upload
|
||||
// endpoint is never hit. No httpmock stub is registered, so if uploadSheetImage
|
||||
// ever tried to POST upload_all the RoundTrip would return a
|
||||
// "no stub for POST ..." network failure — that would surface as a
|
||||
// non-validation category and fail the metadata assertion below. The
|
||||
// category=validation + fs.ErrNotExist cause therefore strictly implies the
|
||||
// short-circuit happened before the wire.
|
||||
func TestUploadSheetImage_FileOpenError(t *testing.T) {
|
||||
runtime, _ := newSheetMediaTestRuntime(t)
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, err := uploadSheetImage(runtime, "shtcnTOK123", "missing.png", "missing.png", 1)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v; want typed problem carrier", err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q (non-validation implies the upload endpoint was reached)", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("err = %v; want wrapped fs.ErrNotExist cause to be preserved", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newSheetMediaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-sheets-media-" + t.Name(),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "sheets"}, cfg, f, core.AsBot)
|
||||
return runtime, reg
|
||||
}
|
||||
|
||||
type sheetMediaCapturedMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeSheetMediaMultipartBody(t *testing.T, stub *httpmock.Stub) sheetMediaCapturedMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := sheetMediaCapturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(part); err != nil {
|
||||
t.Fatalf("read multipart body for %q: %v", part.FormName(), err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = buf.Bytes()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = buf.String()
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -11,8 +11,6 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +204,13 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
// 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 != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ 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",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -55,8 +54,10 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
// 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 _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -646,12 +647,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(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) {
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -664,7 +665,6 @@ func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -43,10 +43,8 @@ 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 is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "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},
|
||||
@@ -55,15 +53,9 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); 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")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -16,21 +15,6 @@ 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.
|
||||
|
||||
@@ -34,9 +34,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
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.
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,23 +17,11 @@ 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{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
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) {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
|
||||
@@ -150,8 +150,6 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
||||
- 日期时间 `2026-04-15T10:00:00`
|
||||
- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00`
|
||||
|
||||
> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC)。CI(UTC)与本地(如 UTC+8)跑同一条命令,时间边界会差几小时;要精确锁定时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。`--target`(PITR 恢复)尤其建议带时区,避免恢复到非预期时间点。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
||||
|
||||
@@ -57,7 +57,7 @@ lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --out
|
||||
```
|
||||
|
||||
### +file-upload
|
||||
上传一个本地文件。文件名沿用本地文件名(特殊字符做 URL 编码透传;以 `.` 开头的隐藏文件名会加 `_` 前缀,避免下载回本地时覆盖隐藏文件),远端路径由平台分配。单文件上限 100 MB。
|
||||
上传一个本地文件。文件名沿用本地文件名,远端路径由平台分配。单文件上限 100 MB。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf
|
||||
@@ -86,8 +86,6 @@ lark-cli apps +file-quota-get --app-id app_xxx
|
||||
- 日期时间 `2026-04-15T10:00:00`
|
||||
- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00`
|
||||
|
||||
> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC 发给服务端)。CI(UTC)与本地(如 UTC+8)跑同一条命令,过滤边界会差几小时;要精确到某时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。
|
||||
|
||||
@@ -8,6 +8,10 @@ metadata:
|
||||
cliHelp: "lark-cli contact --help"
|
||||
---
|
||||
|
||||
# contact (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# +search-user
|
||||
|
||||
仅支持 user 身份。
|
||||
仅 user 身份。需要 scope `contact:user:search`。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- ✅ 已知姓名 / 邮箱 / 「聊过的人」想找出 open_id
|
||||
- ✅ 已知一组 open_id 想批量校验或回填字段(`--user-ids`,最多 100,支持 `me`)
|
||||
- ✅ 按聊天关系 / 在职状态 / 租户边界 / 企业邮箱等维度筛选员工
|
||||
- ❌ 已知 open_id 想拿完整 profile → 用 `+get-user --as bot`
|
||||
- ❌ 已知 open_id 想发消息 → 直接走 `lark-im`,不经过本命令
|
||||
|
||||
## 关键 flag
|
||||
|
||||
@@ -33,7 +33,6 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
|
||||
@@ -34,9 +34,9 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
|
||||
- 图片:`<img token="..." .../>`
|
||||
- 文件:`<source token="..." name="..."/>`
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
- 画板:`<whiteboard token="..."/>`
|
||||
|
||||
## 排障
|
||||
|
||||
@@ -30,9 +30,9 @@ lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
|
||||
- 图片:`<img token="..." .../>`
|
||||
- 文件:`<source token="..." name="..."/>`
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
|
||||
| `--pattern` | 视指令 | 匹配文本(str_replace) |
|
||||
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
|
||||
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
|
||||
|
||||
@@ -16,11 +16,8 @@ metadata:
|
||||
|
||||
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。
|
||||
|
||||
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
@@ -164,7 +161,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
### files
|
||||
|
||||
- `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制
|
||||
- `copy` — 复制文件
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
|
||||
- `patch` — 修改文件标题
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-event
|
||||
version: 1.0.0
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -149,6 +149,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
|------------|------------------------------------------------------------------------------|---|
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
|
||||
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |
|
||||
|
||||
@@ -2,60 +2,48 @@
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (4)
|
||||
## Key catalog (2)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `vc.meeting.participant_meeting_started_v1` | A meeting the current user participates in has started |
|
||||
| `vc.meeting.participant_meeting_joined_v1` | The current user has joined a meeting |
|
||||
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
|
||||
| `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) |
|
||||
|
||||
All four keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. All require `--as user`.
|
||||
Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both require `--as user`.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `vc.meeting.participant_meeting_started_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
| `vc.meeting.participant_meeting_joined_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
| `vc.note.generated_v1` | `vc:note:read` | user |
|
||||
|
||||
---
|
||||
|
||||
## Meeting participant events
|
||||
|
||||
Covered keys:
|
||||
|
||||
- `vc.meeting.participant_meeting_started_v1`
|
||||
- `vc.meeting.participant_meeting_joined_v1`
|
||||
- `vc.meeting.participant_meeting_ended_v1`
|
||||
## `vc.meeting.participant_meeting_ended_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; one of the covered meeting participant EventKeys |
|
||||
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `meeting_id` | string | Meeting ID |
|
||||
| `topic` | string | Meeting topic |
|
||||
| `meeting_no` | string | Meeting number |
|
||||
| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone |
|
||||
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone |
|
||||
| `calendar_event_id` | string | Calendar event ID associated with the meeting |
|
||||
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone; only present for `vc.meeting.participant_meeting_ended_v1` |
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty. `end_time` is emitted only for `vc.meeting.participant_meeting_ended_v1`.
|
||||
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty.
|
||||
- No detail API call is made; all fields come from the event payload itself.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
lark-cli event consume vc.meeting.participant_meeting_started_v1 --as user
|
||||
lark-cli event consume vc.meeting.participant_meeting_joined_v1 --as user
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
|
||||
|
||||
# Project meeting topic and end time only
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-shared
|
||||
version: 1.0.0
|
||||
description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, or handling _notice JSON."
|
||||
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
@@ -23,28 +23,6 @@ lark-cli config init --new
|
||||
|
||||
## 认证
|
||||
|
||||
### 认证任务速查
|
||||
|
||||
认证、scope、业务域、登录态、退出登录态、撤销授权问题都走本技能。
|
||||
|
||||
| 用户意图 | 首选命令 / 回答 |
|
||||
|---|---|
|
||||
| 获取全部权限 | `lark-cli auth login --domain all --no-wait --json` |
|
||||
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json`;`--domain` 可重复,也可用逗号分隔 |
|
||||
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
|
||||
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity`、`verified`、`identities.user.status`、`identities.user.userName`、`identities.user.openId`(用户 open id)、`identities.user.tokenStatus`、`identities.user.scope` |
|
||||
| 快速看当前生效身份(人类可读) | `lark-cli whoami`;聚焦实际生效的那一个身份(走 `--as`/`default-as`/strict-mode 解析,可能与 `auth status` 的「第一个可用身份」不同),含当前 profile。脚本/agent 取结构化结果加 `--json`(字段 `identity`、`identitySource`、`available`、`tokenStatus`)。深度诊断 / 服务器校验仍用 `auth status --json --verify` |
|
||||
| 退出当前机器的用户登录态 | `lark-cli auth logout --json`;`loggedOut:true` 表示注销成功 |
|
||||
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope,优先复用错误里的 `console_url` |
|
||||
| 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 |
|
||||
| 只取消一个 scope | CLI 不支持单独撤销一个已授予 scope;可重新走最小 scope 授权,或让用户在授权管理页处理 |
|
||||
|
||||
机器读取 JSON 时,为减少 `_notice` 干扰,可在命令前加:
|
||||
|
||||
```bash
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli auth status --json --verify
|
||||
```
|
||||
|
||||
### 身份类型
|
||||
|
||||
两种身份类型,通过 `--as` 切换:
|
||||
@@ -130,22 +108,19 @@ lark-cli auth login --device-code <device_code>
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
除非用户正在询问更新、版本或 notice,否则不要把 `_notice` 原样复制为当前任务的主要答案,也不要为了 notice 中断当前任务去反复查 help。
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
|
||||
需要稳定 JSON 给脚本或机器读取时,可以在命令前设置:
|
||||
|
||||
```bash
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 <lark-cli command>
|
||||
```
|
||||
|
||||
当你在输出中看到 `_notice.update` 时,先完成用户当前请求;如仍相关,再简短告知可运行:
|
||||
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
|
||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
|
||||
@@ -4,7 +4,7 @@ version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
@@ -12,55 +12,217 @@ metadata:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
本地 `skills/lark-slides/scripts/` 下的 Python 工具要求 Python 3.10+;如果 `python3 --version` 低于 3.10,先切换到 3.10+ 解释器再运行脚本。
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 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` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `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` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
| 用户需求 | 指引 |
|
||||
|----------|------|
|
||||
| 读取 / 分析本地 PPTX 内容 | 文本用 `python -m markitdown presentation.pptx`;视觉总览用 `python3 scripts/thumbnail.py presentation.pptx`;原始 OOXML 用 `python3 scripts/office/unpack.py presentation.pptx unpacked/` |
|
||||
| 从模板创建或编辑已有本地 PPTX | 先读 `lark-slides-pptx-template-workflows.md` |
|
||||
| 从零新建飞书在线 PPT | 先读 `lark-slides-create-workflows.md` |
|
||||
| 获取在线 slides 内容、读取 / 分析已有在线 PPT | XML 内容优先用 `slides +xml-get` 保存到文件;页面视觉内容用 `slides +screenshot`,详见 `lark-slides-screenshot.md` |
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
## 读取 / 分析内容
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
### 本地 PPTX
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
|
||||
```bash
|
||||
# 提取文本
|
||||
python -m markitdown presentation.pptx
|
||||
|
||||
# 生成视觉总览图
|
||||
python3 scripts/thumbnail.py presentation.pptx
|
||||
|
||||
# 解包查看原始 OOXML
|
||||
python3 scripts/office/unpack.py presentation.pptx unpacked/
|
||||
lark-cli auth login --domain slides
|
||||
```
|
||||
|
||||
### 在线 Slides
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
|
||||
**执行规则**:
|
||||
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
|
||||
## 执行前必做
|
||||
|
||||
> **重要**:`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
高频只读:
|
||||
|
||||
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
|
||||
- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写)
|
||||
- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写)
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`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-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)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
|
||||
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
|
||||
|
||||
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
|
||||
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1-2 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。
|
||||
- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。
|
||||
- **2x2 / 2x3 网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。
|
||||
- **半出血视觉**:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。
|
||||
- **大数字卡片**:关键指标用 60-72pt 数字,下面配 10-14pt 标签。
|
||||
- **对比列**:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
|
||||
- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。
|
||||
|
||||
字体和间距建议:
|
||||
|
||||
- 标题 36-44pt,关键结论可更大;正文 14-18pt;注释 10-12pt。
|
||||
- 正文默认左对齐;只在封面、结尾或大号数字场景中使用居中。
|
||||
- 页面边距至少 40px;内容块之间保持 24-40px 间距,并在同一 deck 内保持一致。
|
||||
- 卡片内边距要真实留出空间,不要让文字贴边;对齐 shape 和文字时要考虑文本框 padding。
|
||||
|
||||
常见错误必须避免:
|
||||
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
|
||||
```bash
|
||||
# 读取完整 XML 内容,优先保存到文件再分析
|
||||
lark-cli slides +xml-get --as user --presentation <slides_url_or_token> --output presentation.xml --json
|
||||
|
||||
# 获取页面截图;必须指定 --slide-number 或 --slide-id,多个页面可重复传 --slide-number
|
||||
lark-cli slides +screenshot --as user --presentation <slides_url_or_token> --slide-number 1 --output-dir screenshots --json
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
```
|
||||
|
||||
在线 Slides 的截图参数和页码语义详见 [`lark-slides-screenshot.md`](references/lark-slides-screenshot.md);需要继续编辑在线 Slides 时,按 `lark-slides-create-workflows.md` / `lark-slides-replace-workflows.md` 选择创建或替换流程。
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
## 编辑 PPTX 工作流
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
**完整流程先读 [`lark-slides-pptx-template-workflows.md`](references/lark-slides-pptx-template-workflows.md)。**
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
1. 用 `thumbnail.py` 和 `markitdown` 分析模板。
|
||||
2. 解包 -> 调整页面结构 -> 编辑内容 -> 清理 -> 打包。
|
||||
3. 交付前完成必需 QA。
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
## 从零创建
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
**完整流程先读 [`lark-slides-create-workflows.md`](references/lark-slides-create-workflows.md)。**
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
当没有本地 PPTX 模板 / 参考演示文稿,或目标是新建飞书 / Lark 在线 Slides 而不是本地 `.pptx` 文件时,使用该流程。
|
||||
```bash
|
||||
# 追加到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级
|
||||
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
```
|
||||
|
||||
> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
### 大纲模板
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
|
||||
模板:[未使用模板 / <category>/<template>.xml(推荐原因)]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -97,126 +259,34 @@ Slides (演示文稿)
|
||||
└── slide_id (页面唯一标识)
|
||||
```
|
||||
|
||||
## 身份选择
|
||||
## Shortcuts 与 API
|
||||
|
||||
飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+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/>`,不改变页序 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain slides
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
**执行规则**:
|
||||
## 核心规则
|
||||
|
||||
1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
|
||||
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
|
||||
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 设计思路
|
||||
|
||||
不要交付只有白底、标题和项目符号的幻灯片。正式页面至少要在视觉元素、信息结构、配色、字体或留白上体现明确设计意图。
|
||||
|
||||
### 开始前
|
||||
|
||||
- **配色贴合主题**:配色要回应本次主题、行业和受众,不要套用通用蓝色商务风;如果换到另一个完全无关主题也成立,说明选择还不够具体。
|
||||
- **建立视觉主次**:主色承担约 60-70% 视觉权重,1-2 个辅助色负责分区和层级,强调色只用于关键数字、结论或行动点。
|
||||
- **规划明暗节奏**:可采用深色封面、浅色内容、深色结尾的结构,也可以全篇深色;无论哪种策略,都要保证正文、图标和线条有足够对比度。
|
||||
- **固定一个视觉母题**:选择一个可复用元素贯穿全文,例如圆角图片框、彩色圆形图标底、粗侧边栏、编号节点、半出血图片区或大号数字,避免每页换一套装饰语言。
|
||||
|
||||
### 配色参考
|
||||
|
||||
根据内容选择颜色,不要把蓝色当作默认答案。下表只提供方向感,实际使用时可按页面明暗、透明度和对比需求微调。
|
||||
|
||||
| 风格 | 主色 | 辅助色 | 强调色 |
|
||||
|------|------|--------|--------|
|
||||
| **深夜高管风** | `1E2761`(深海军蓝) | `CADCFC`(冰蓝) | `FFFFFF`(白) |
|
||||
| **森林苔藓风** | `2C5F2D`(森林绿) | `97BC62`(苔绿色) | `F5F5F5`(浅米白) |
|
||||
| **珊瑚活力风** | `F96167`(珊瑚红) | `F9E795`(金黄) | `2F3C7E`(藏青) |
|
||||
| **暖陶土风** | `B85042`(陶土红) | `E7E8D1`(沙色) | `A7BEAE`(鼠尾草绿) |
|
||||
| **海洋渐变风** | `065A82`(深海蓝) | `1C7293`(蓝绿色) | `21295C`(午夜蓝) |
|
||||
| **炭灰极简风** | `36454F`(炭灰) | `F2F2F2`(灰白) | `212121`(近黑) |
|
||||
| **青绿信任风** | `028090`(青蓝) | `00A896`(海沫绿) | `02C39A`(薄荷绿) |
|
||||
| **莓果奶油风** | `6D2E46`(莓紫) | `A26769`(玫瑰灰) | `ECE2D0`(奶油色) |
|
||||
| **鼠尾草冷静风** | `84B59F`(鼠尾草绿) | `69A297`(桉叶绿) | `50808E`(石板蓝) |
|
||||
| **樱桃强对比风** | `990011`(樱桃红) | `FCF6F5`(暖白) | `2F3C7E`(藏青) |
|
||||
|
||||
### 单页设计
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
|
||||
布局可选:
|
||||
|
||||
- 双栏结构:左文右图或左图右文,视觉区域占 35-45% 宽度。
|
||||
- 图标文本行:图标放在色块或圆形底中,旁边配短标题和一句解释。
|
||||
- 2x2 / 2x3 网格:适合能力、模块、风险、行动项,每格内容保持同等层级。
|
||||
- 半出血视觉:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。
|
||||
|
||||
数据展示可选:
|
||||
|
||||
- 大数字卡片:关键指标用 60-72pt 数字,下面配 10-14pt 标签。
|
||||
- 对比列:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
|
||||
- 时间线或流程图:步骤用编号节点和箭头表达,流程方向必须一眼可见。
|
||||
|
||||
视觉细节可选:
|
||||
|
||||
- section header 旁可以放小号彩色圆形图标。
|
||||
- 关键数字、tagline 或结论短句可用斜体或强调色,但不要把整段正文都做成强调样式。
|
||||
|
||||
### 字体排版
|
||||
|
||||
标题字体可以更有性格,正文字体必须清晰耐读;不要整份 deck 都默认 Arial。生成 XML 时,`fontFamily` 应使用以下支持字体的精确名称;同一份 deck 内优先选择 1-2 个字体家族,避免每页混用太多字体。
|
||||
|
||||
| 标题字体 | 正文字体 |
|
||||
|----------|----------|
|
||||
| Arial Black | Arial |
|
||||
| Georgia | Calibri |
|
||||
| Trebuchet MS | Calibri |
|
||||
| Playfair Display | Lato |
|
||||
| Montserrat | Open Sans |
|
||||
| 思源宋体 | 思源黑体 |
|
||||
|
||||
| 元素 | 字号 |
|
||||
|------|------|
|
||||
| 页面标题 | 36-44pt,加粗 |
|
||||
| 分区标题 | 20-24pt,加粗 |
|
||||
| 正文 | 14-16pt |
|
||||
| 注释/来源 | 10-12pt,弱化处理 |
|
||||
|
||||
#### 常用中文字体
|
||||
|
||||
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、寒蝉团圆体 圆体、寒蝉团圆体 黑体、荆南缘默体、寒蝉端黑宋、资源圆体、钟齐流江毛草、寒蝉端黑体、站酷庆科黄油体、寒蝉云墨黑、有字库龙藏体、寒蝉全圆体、思源黑体、钟齐志莽行书、抖音美好体、马善政毛笔楷体、霞鹜 975 圆体
|
||||
|
||||
#### 常用拉丁字体
|
||||
|
||||
Francois One、Heebo、Lobster、Roboto Slab、Varela Round、PT Serif、Signika、Vollkorn、Mulish、Rokkitt、Inconsolata、PT Sans Caption、EB Garamond、Dancing Script、Rajdhani、Poppins、Merriweather、PT Sans Narrow、Libre Baskerville、Slabo 27px、Inter、Noto Serif、Yanone Kaffeesatz、Merriweather Sans、Lato、Source Code Pro、Mukta、Teko、Hind Siliguri、Catamaran、Arvo、Alegreya Sans、Titillium Web、Roboto Mono、Play、Indie Flower、Ubuntu Condensed、Libre Franklin、Barlow、PT Sans、Acme、Cuprum、Josefin Sans、DM Sans、Playfair Display、Rubik、Questrial、Anton、Oswald、Cabin、Ubuntu、Abel、Exo 2、Bree Serif、Roboto Condensed、Amatic SC、Abril Fatface、Comfortaa、IBM Plex Sans、Work Sans、Kanit、Noto Sans、Alegreya、Shadows Into Light、Barlow Condensed、Nunito Sans、Quicksand、Overpass、Bebas Neue、Raleway、Exo、Archivo Narrow、Hind、Open Sans、Poiret One、Asap、Roboto、Nunito、Bitter、Dosis、Oxygen、Prompt、Karla、Fjalla One、Fira Sans、Crimson Text、Pacifico、Arimo、Maven Pro、Cairo、Montserrat、Righteous、Lora
|
||||
|
||||
#### 其他语言字体
|
||||
|
||||
源ノ角ゴシック、본고딕、Nanum Gothic
|
||||
|
||||
#### 系统字体
|
||||
|
||||
Arial、Arial Black、Calibri、Comic Sans Ms、Sans Serif、Serif、Times New Roman、Tahoma、Trebuchet MS、Verdana、Georgia、Garamond、黑体、宋体、楷体、Hiragino Mincho
|
||||
|
||||
### 留白与间距
|
||||
|
||||
- 页面边距至少 0.5"。
|
||||
- 内容块之间保持 0.3-0.5" 间距,并在同一 deck 内保持一致。
|
||||
- 留出呼吸感,不要填满每一寸空间。
|
||||
- 卡片内边距要真实留出空间;对齐 shape 和文字时要考虑文本框 padding,必要时给文本框设置 `margin: 0`。
|
||||
|
||||
### 避免事项
|
||||
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要正文居中;段落和列表默认左对齐,只在封面、结尾或大号数字场景中居中。
|
||||
- 不要缺少字号层级;标题需要 36pt+,明显区别于 14-16pt 正文。
|
||||
- 不要默认蓝色;配色要反映具体主题。
|
||||
- 不要随机混用间距;选择 0.3" 或 0.5" 间距后全 deck 统一。
|
||||
- 不要只设计一页,其余页面保持 plain;要么全篇贯彻设计语言,要么整体保持克制。
|
||||
- 不要创建纯文本页;必须加入图片、图标、图表或其他视觉元素,避免 plain title + bullets。
|
||||
- 不要忘记文本框 padding;线条或 shape 与文字边缘对齐时,设置 `margin: 0` 或为 padding 做偏移。
|
||||
- 不要使用低对比元素;图标和文字都必须和背景有强对比。
|
||||
- 不要在标题下方画一条装饰强调线;这类做法很容易显得模板化。优先用留白、背景色块或结构分区建立层级。
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli slides --help"
|
||||
---
|
||||
|
||||
# slides (v1)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-replace-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` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`../scripts/xml_text_overlap_lint.py`](../scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`../scripts/template_tool.py`](../scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
> [!NOTE]
|
||||
> `../scripts/template_tool.py` 需要 Python 3。`template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`../assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-replace-workflows.md`](lark-slides-replace-workflows.md)。
|
||||
|
||||
## 执行前必做
|
||||
|
||||
> **重要**:`slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
|
||||
|
||||
高频只读:
|
||||
|
||||
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md)
|
||||
- [planning-layer.md](planning-layer.md)(新建 / 大幅改写)
|
||||
- [visual-planning.md](visual-planning.md)(新建 / 大幅改写)
|
||||
- [asset-planning.md](asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-replace-workflows.md`](lark-slides-replace-workflows.md)、[`lark-slides-replace-slide.md`](lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](iconpark.md)、[`../scripts/iconpark_tool.py`](../scripts/iconpark_tool.py)
|
||||
- 模板:[`template-catalog.md`](template-catalog.md)、[`../scripts/template_tool.py`](../scripts/template_tool.py)
|
||||
- 排障:[`troubleshooting.md`](troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
模板细则见 [template-catalog.md](template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
|
||||
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
|
||||
```
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
|
||||
<data>
|
||||
在这里放置 shape、line、table、chart、whiteboard 等元素
|
||||
</data>
|
||||
</slide>' '{slide:{content:$content}}')"
|
||||
|
||||
# 插到指定页之前:before_slide_id 必须在 --data body 里,与 slide 同级
|
||||
# ⚠️ 不要把 before_slide_id 写进 --params —— CLI 会当未知 query 参数静默下发,服务端忽略,新页跑到末尾
|
||||
lark-cli slides xml_presentation.slide create \
|
||||
--as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}' \
|
||||
--data "$(jq -n --arg content '<slide ...>...</slide>' --arg before 'TARGET_SLIDE_ID' \
|
||||
'{slide:{content:$content}, before_slide_id:$before}')"
|
||||
```
|
||||
|
||||
> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
|
||||
|
||||
### 大纲模板
|
||||
|
||||
生成大纲时使用以下格式,交给用户确认:
|
||||
|
||||
```text
|
||||
[PPT 标题] — [定位描述],面向 [目标受众]
|
||||
|
||||
模板:[未使用模板 / <category>/<template>.xml(推荐原因)]
|
||||
|
||||
页面结构(N 页):
|
||||
1. 封面页:[标题文案]
|
||||
2. [页面主题]:[要点1]、[要点2]、[要点3]
|
||||
3. [页面主题]:[要点描述]
|
||||
...
|
||||
N. 结尾页:[结尾文案]
|
||||
|
||||
风格:[配色方案],[排版风格]
|
||||
```
|
||||
|
||||
## Shortcuts 与 API
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 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>` 输出不一致,以后两者为准。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 编辑已有 PPT:读-改-写闭环
|
||||
|
||||
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
|
||||
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。
|
||||
|
||||
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
| 已知某块的 `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`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -137,7 +136,6 @@ 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
|
||||
@@ -1,432 +0,0 @@
|
||||
# PPTX 模板编辑工作流
|
||||
|
||||
本文用于用户给定 `.pptx` 模板或已有 `.pptx`,并要求基于它编辑、改写、续写、美化或生成新的 PPTX。流程对齐 Anthropic `skills/pptx/editing.md` 的实现思路:先分析模板,再规划页面映射,先完成结构调整,最后逐页编辑内容;除非用户明确不要导入为飞书 Slides,否则最终默认导入并交付在线 Slides。
|
||||
|
||||
> **边界**:本流程的编辑阶段只使用 `skills/lark-slides/scripts/` 下的 PPTX / OOXML 脚本和本地 OOXML 编辑,不使用 `lark-cli slides`、`xml_presentations.*`、`slides_xml_schema_definition.xml`、`template_tool.py`、`iconpark_tool.py`、`xml_text_overlap_lint.py`、`+replace-slide`、`+replace-pages`、`+media-upload` 或任何飞书在线 Slides 组件。交付阶段按后文 [导入为飞书 Slides (Required)](#导入为飞书-slides-required) 执行。
|
||||
|
||||
> **Python 版本**:本流程中的 Python 脚本要求 Python 3.10+。执行前先确认 `python3 --version`;如果环境仍是 Python 3.9.x,不要继续运行这些脚本,先切换到 Python 3.10+。
|
||||
|
||||
## Template-Based Workflow
|
||||
|
||||
### 1. 分析模板
|
||||
|
||||
先生成模板缩略图,再提取可读文本。缩略图用于看版式,文本输出用于识别占位内容。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/thumbnail.py \
|
||||
"work/<task-id>/template.pptx" \
|
||||
"work/<task-id>/template-thumbnails" \
|
||||
--cols 4
|
||||
|
||||
python -m markitdown "work/<task-id>/template.pptx" \
|
||||
> "work/<task-id>/template.md"
|
||||
```
|
||||
|
||||
如果当前环境没有 `markitdown`,不要安装依赖;改为解包后直接阅读 `ppt/slides/slide*.xml` 中的文本占位符。
|
||||
|
||||
分析时记录:
|
||||
|
||||
- 每页的 `slideN.xml` 文件名、页序、页面用途和可复用程度。
|
||||
- 封面、目录、章节页、内容页、图文页、数据页、引用页、结尾页等页型。
|
||||
- 每页的占位文本、图片槽、图表槽、图标槽、页脚和备注。
|
||||
- 哪些页面适合复制,哪些页面应删除。
|
||||
|
||||
### 2. 规划页面映射
|
||||
|
||||
为每个内容章节选择一个模板页面。不要默认使用标题 + bullets 的基础页。
|
||||
|
||||
优先主动寻找并复用不同版式:
|
||||
|
||||
- 2 栏 / 3 栏布局
|
||||
- 图片 + 文本组合
|
||||
- 全出血图片 + 文字覆盖
|
||||
- 引用 / callout 页
|
||||
- 章节分隔页
|
||||
- 关键数字 / 指标页
|
||||
- 图标网格或图标 + 文本行
|
||||
- 表格、流程、时间线或图表页
|
||||
|
||||
避免每一页都重复同一种文本密集版式。内容类型要匹配页面风格:关键点可以用列表页,团队或模块信息适合多栏页,证言或观点适合引用页,指标适合大数字页。
|
||||
|
||||
### 3. 解包
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/office/unpack.py \
|
||||
"work/<task-id>/template.pptx" \
|
||||
"work/<task-id>/unpacked"
|
||||
```
|
||||
|
||||
`office/unpack.py` 会解包 PPTX、格式化 XML / `.rels`,并处理智能引号转义。后续编辑都在 `unpacked/` 内完成。
|
||||
|
||||
如果中途使用 PowerPoint、LibreOffice、`python-pptx` 或其他外部工具直接修改了 packed `.pptx` 文件,旧的 `unpacked/` 目录就不再代表最新内容。后续若要回到本脚本工作流继续编辑,必须先把最新 `.pptx` 重新 unpack 到新的目录,或清空旧目录后重新 unpack;不要继续基于过期的 `unpacked/` 打包。
|
||||
|
||||
### 4. 构建演示文稿结构
|
||||
|
||||
结构调整必须在内容编辑之前完成,并由主 agent 自己做,不要交给子 agent 并行处理。
|
||||
|
||||
- 删除不用的页面:从 `ppt/presentation.xml` 的 `<p:sldIdLst>` 中移除对应 `<p:sldId>`,然后运行 `clean.py` 清理孤立文件。
|
||||
- 复制要复用的页面:用 `add_slide.py`,不要手动复制 slide 文件。
|
||||
- 从版式创建页面:用 `add_slide.py unpacked/ slideLayoutN.xml`。
|
||||
- 调整页序:重排 `ppt/presentation.xml` 中 `<p:sldIdLst>` 的 `<p:sldId>` 顺序。
|
||||
- 完成所有新增、删除、复制、重排后,再进入内容编辑阶段。
|
||||
|
||||
```bash
|
||||
# 复制已有页面,例如基于 slide2.xml 创建新页
|
||||
python3 skills/lark-slides/scripts/add_slide.py \
|
||||
"work/<task-id>/unpacked" \
|
||||
slide2.xml
|
||||
|
||||
# 或基于 slide layout 创建空白新页
|
||||
python3 skills/lark-slides/scripts/add_slide.py \
|
||||
"work/<task-id>/unpacked" \
|
||||
slideLayout2.xml
|
||||
```
|
||||
|
||||
`add_slide.py` 会补充 slide 文件、content type 和 presentation relationship,并打印需要加入 `ppt/presentation.xml` 的 `<p:sldId .../>`。把这行插入到目标页序位置。
|
||||
|
||||
### 5. 编辑内容
|
||||
|
||||
结构稳定后,再逐页编辑 `ppt/slides/slideN.xml`。每页是独立 XML 文件,可以在这个阶段并行处理;如果使用子 agent,提示必须包含:
|
||||
|
||||
- 要编辑的 slide 文件路径。
|
||||
- 使用 Edit 工具做所有改动。
|
||||
- 本文的格式规则和常见坑。
|
||||
|
||||
每页处理顺序:
|
||||
|
||||
1. 读取该页 XML。
|
||||
2. 找出所有占位内容:文本、图片、图表、图标、caption、来源说明。
|
||||
3. 将每个占位内容替换为最终内容。
|
||||
4. 删除多余元素,而不是只清空文字。
|
||||
5. 保留模板原有段落、run、对齐、字体、字号、颜色和间距结构。
|
||||
|
||||
> **重要**:内容替换优先使用 Edit 工具,不要用 `sed` 或临时 Python 脚本批量替换。Edit 工具会迫使修改点具体化,可靠性更高。
|
||||
|
||||
### 6. 清理
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/clean.py \
|
||||
"work/<task-id>/unpacked"
|
||||
```
|
||||
|
||||
`clean.py` 会移除不在 `<p:sldIdLst>` 中的页面、未引用媒体、孤立 `.rels`、未引用图表 / diagram / drawing / theme / notes,并更新 `[Content_Types].xml`。
|
||||
|
||||
### 7. 打包
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/office/pack.py \
|
||||
"work/<task-id>/unpacked" \
|
||||
"work/<task-id>/output.pptx" \
|
||||
--original "work/<task-id>/template.pptx"
|
||||
```
|
||||
|
||||
`office/pack.py` 会先做 PPTX 校验,再压缩 XML 并打包。若只需要重写 ZIP 压缩,不改变内容,可用:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/rezip.py \
|
||||
"work/<task-id>/output.pptx" \
|
||||
"work/<task-id>/output.rezipped.pptx"
|
||||
```
|
||||
|
||||
### 8. 视觉 QA
|
||||
|
||||
必须按后文 [QA (Required)](#qa-required) 完成内容 QA、图片渲染、视觉检查和修复复验。`thumbnail.py` 可用于快速页序检查;最终视觉 QA 优先使用 [Converting to Images](#converting-to-images) 生成的全分辨率页面图。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/thumbnail.py \
|
||||
"work/<task-id>/output.pptx" \
|
||||
"work/<task-id>/preview/thumbnails" \
|
||||
--cols 4
|
||||
```
|
||||
|
||||
## QA (Required)
|
||||
|
||||
默认假设第一版一定有问题。QA 的目标不是确认成功,而是主动找出缺陷并修到没有新问题。
|
||||
|
||||
### Content QA
|
||||
|
||||
用文本抽取检查内容是否缺失、顺序是否错误、是否还有模板占位文案。
|
||||
|
||||
```bash
|
||||
python -m markitdown "work/<task-id>/output.pptx" \
|
||||
> "work/<task-id>/qa/content.md"
|
||||
```
|
||||
|
||||
模板编辑必须检查残留占位词。命中任何结果都要先修复,再进入最终交付。
|
||||
|
||||
```bash
|
||||
python -m markitdown "work/<task-id>/output.pptx" \
|
||||
| grep -iE "xxxx|lorem|ipsum|this.*(page|slide).*layout|placeholder|占位|示例|请替换"
|
||||
```
|
||||
|
||||
检查重点:
|
||||
|
||||
- 内容是否完整,没有漏掉用户材料里的章节、数字、结论或来源。
|
||||
- 页序和章节顺序是否正确。
|
||||
- 标题、图表标签、脚注、页脚、引用和备注是否仍匹配当前内容。
|
||||
- 是否残留模板说明文字、占位头像、空卡片、默认图表数据或示例 logo。
|
||||
|
||||
### Visual QA
|
||||
|
||||
把 PPTX 转成逐页图片后检查。即使只有 2-3 页,也建议让另一个 agent / reviewer 用新视角看图;自己盯着 XML 太久,很容易只看到预期结果。
|
||||
|
||||
检查时使用类似提示:
|
||||
|
||||
```text
|
||||
请视觉检查这些幻灯片。默认其中存在问题,请主动找出它们。
|
||||
|
||||
重点检查:
|
||||
- 元素重叠:文字穿过形状、线条穿过文字、多个元素堆叠。
|
||||
- 文本溢出或在文本框 / 页面边缘被裁切。
|
||||
- 装饰线、分割线或背景块只适配单行标题,但标题实际换成两行。
|
||||
- 来源、脚注或页码与正文内容碰撞。
|
||||
- 元素距离太近,卡片、栏目或段落之间小于 0.3 英寸。
|
||||
- 外边距不足,内容距离页面边缘小于 0.5 英寸。
|
||||
- 同类元素未对齐,栏宽、卡片高度、图标位置不一致。
|
||||
- 低对比度文字或图标。
|
||||
- 文本框过窄导致异常换行。
|
||||
- 残留占位内容、空头像框、空图标底座、默认图表数据。
|
||||
|
||||
逐页列出所有问题或可疑区域,即使只是轻微问题。
|
||||
```
|
||||
|
||||
给 reviewer / subagent 的输入应包含每张图的路径和预期内容:
|
||||
|
||||
```text
|
||||
Read and analyze these images:
|
||||
1. /abs/path/slide-01.jpg (Expected: 封面,包含标题、副标题、主视觉)
|
||||
2. /abs/path/slide-02.jpg (Expected: 三栏方案对比,包含 A/B/C 三个模块)
|
||||
|
||||
Report all issues found, including minor ones.
|
||||
```
|
||||
|
||||
### Verification Loop
|
||||
|
||||
1. 生成 PPTX。
|
||||
2. 转成图片。
|
||||
3. 检查并列出问题;如果第一轮没有发现任何问题,要更严格地重新看一遍。
|
||||
4. 修复问题。
|
||||
5. 重新打包并只复验受影响页面。
|
||||
6. 重复直到一整轮检查没有新增问题。
|
||||
|
||||
不要在至少完成一次“发现问题 → 修复 → 复验”的闭环前宣称完成。
|
||||
|
||||
## Converting to Images
|
||||
|
||||
将 PPTX 转为逐页图片,用于视觉检查。
|
||||
|
||||
```bash
|
||||
mkdir -p "work/<task-id>/preview"
|
||||
|
||||
python3 skills/lark-slides/scripts/office/soffice.py \
|
||||
--headless \
|
||||
--convert-to pdf \
|
||||
--outdir "work/<task-id>/preview" \
|
||||
"work/<task-id>/output.pptx"
|
||||
|
||||
pdftoppm \
|
||||
-jpeg \
|
||||
-r 150 \
|
||||
"work/<task-id>/preview/output.pdf" \
|
||||
"work/<task-id>/preview/slide"
|
||||
```
|
||||
|
||||
这会生成 `slide-1.jpg`、`slide-2.jpg` 等文件。给 reviewer 时可按实际文件名列出;若需要稳定的两位编号,可在本地重命名为 `slide-01.jpg`、`slide-02.jpg`。
|
||||
|
||||
修复后只重渲染某几页:
|
||||
|
||||
```bash
|
||||
pdftoppm \
|
||||
-jpeg \
|
||||
-r 150 \
|
||||
-f N \
|
||||
-l N \
|
||||
"work/<task-id>/preview/output.pdf" \
|
||||
"work/<task-id>/preview/slide-fixed"
|
||||
```
|
||||
|
||||
也可以用 `thumbnail.py` 生成总览图,辅助检查页序、隐藏页和整体节奏:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/thumbnail.py \
|
||||
"work/<task-id>/output.pptx" \
|
||||
"work/<task-id>/preview/thumbnails" \
|
||||
--cols 4
|
||||
```
|
||||
|
||||
如果 LibreOffice / `soffice` 在本机崩溃或无法生成 PDF,可用系统预览能力做临时降级检查,但最终交付前仍应优先使用成功渲染出的逐页图片、在线截图或人工打开 PPTX 复验。
|
||||
|
||||
```bash
|
||||
qlmanage -t -s 1200 -o "work/<task-id>/preview" "work/<task-id>/output.pptx"
|
||||
```
|
||||
|
||||
## 导入为飞书 Slides (Required)
|
||||
|
||||
本流程的编辑阶段只处理本地 PPTX。除非用户明确说明“不导入为飞书 Slides”或“只要本地 PPTX”,否则先完成本地 QA,再切到 `lark-drive` 导入,并把在线 Slides 作为默认交付物。
|
||||
|
||||
```bash
|
||||
lark-cli drive +import --as user \
|
||||
--file "work/<task-id>/output.pptx" \
|
||||
--type slides \
|
||||
--name "<title>" \
|
||||
--json
|
||||
```
|
||||
|
||||
导入成功后保存返回的 `url` / `token`,最终回复中必须交付 Slides 链接。需要在线视觉复验时,用 `slides +screenshot` 指定页码;多页截图优先在一次命令里重复传 `--slide-number`,避免紧密循环触发频控。
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation "<slides_url_or_token>" \
|
||||
--slide-number 1 \
|
||||
--slide-number 2 \
|
||||
--output-dir "work/<task-id>/online-screenshots" \
|
||||
--json
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
本流程依赖以下工具;缺失时先说明不能完成对应验证,不要静默跳过。
|
||||
|
||||
- Python 3.10+:`skills/lark-slides/scripts/` 下的脚本使用 Python 3.10+ 语法,不支持 Python 3.9.x。
|
||||
- `markitdown[pptx]`:文本抽取和占位内容检查。
|
||||
- `Pillow`:`thumbnail.py` 生成缩略图网格。
|
||||
- LibreOffice (`soffice`):PPTX 转 PDF;本仓通过 `skills/lark-slides/scripts/office/soffice.py` 包装调用环境。
|
||||
- Poppler (`pdftoppm`):PDF 转逐页图片。
|
||||
- `defusedxml`:OOXML 脚本安全解析 XML。
|
||||
|
||||
不把 `pptxgenjs` 作为本模板编辑流程的必需依赖;它属于从零创建 PPTX 的路线,不是本文件的默认工作流。
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `office/unpack.py` | 解包并 pretty-print PPTX XML / `.rels` |
|
||||
| `add_slide.py` | 复制 slide 或从 slideLayout 创建 slide |
|
||||
| `clean.py` | 删除孤立页面、媒体、关系和 content type |
|
||||
| `office/pack.py` | 校验并重新打包 PPTX |
|
||||
| `office/validate.py` | 单独校验解包目录或 PPTX 文件 |
|
||||
| `thumbnail.py` | 生成带 slide 文件名标签的缩略图网格 |
|
||||
| `rezip.py` | 仅重写 ZIP 压缩,不改内容 |
|
||||
|
||||
### office/unpack.py
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/office/unpack.py input.pptx unpacked/
|
||||
```
|
||||
|
||||
解包 PPTX,格式化 XML,转义智能引号。
|
||||
|
||||
### add_slide.py
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/add_slide.py unpacked/ slide2.xml
|
||||
python3 skills/lark-slides/scripts/add_slide.py unpacked/ slideLayout2.xml
|
||||
```
|
||||
|
||||
第一种复制已有页面,第二种从 layout 创建页面。脚本会打印需要插入 `ppt/presentation.xml` 的 `<p:sldId>`。
|
||||
|
||||
### clean.py
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/clean.py unpacked/
|
||||
```
|
||||
|
||||
清理不再被 presentation 或 slide relationships 引用的文件。
|
||||
|
||||
### office/pack.py
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/office/pack.py \
|
||||
unpacked/ output.pptx --original input.pptx
|
||||
```
|
||||
|
||||
校验、修复可自动修复的问题、压缩 XML,并重新打包 PPTX。
|
||||
|
||||
### thumbnail.py
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/thumbnail.py input.pptx [output_prefix] [--cols N]
|
||||
```
|
||||
|
||||
生成缩略图网格,图片下方标注 `slideN.xml`。默认 3 列,`--cols` 最大值由脚本限制。
|
||||
|
||||
## Slide Operations
|
||||
|
||||
页面顺序由 `ppt/presentation.xml` 里的 `<p:sldIdLst>` 决定。
|
||||
|
||||
- **Reorder**:重排 `<p:sldId>` 元素。
|
||||
- **Delete**:删除 `<p:sldId>`,再运行 `clean.py`。
|
||||
- **Add**:使用 `add_slide.py`。不要手动复制 slide 文件;脚本会处理备注关系、`[Content_Types].xml` 和 relationship ID。
|
||||
|
||||
## Formatting Rules
|
||||
|
||||
- **标题、分组标题和行内标签要加粗**:在对应 run 的 `<a:rPr>` 上使用 `b="1"`。例如页面标题、section header,以及 `Status:`、`Description:` 这类行首标签。
|
||||
- **不要使用 Unicode bullet `•`**:使用 PPTX 原生列表格式,例如 `<a:buChar>` 或 `<a:buAutoNum>`。
|
||||
- **列表样式保持一致**:尽量继承模板 layout 的 bullet 设置;只有模板缺失或需要显式修正时才新增 bullet 属性。
|
||||
- **多条内容不要拼进一个字符串**:编号列表、多步骤、多段说明应拆成多个 `<a:p>`;复制原段落的 `<a:pPr>` 以保留行距和缩进。
|
||||
- **长文本要适配模板槽位**:更长的替换文本可能溢出或异常换行;优先压缩文案、拆页或换用更合适的模板页。
|
||||
- **保留空白时加 `xml:space="preserve"`**:对有前后空格的 `<a:t>` 使用 `xml:space="preserve"`。
|
||||
- **XML 解析用 `defusedxml.minidom`**:不要用 `xml.etree.ElementTree` 重写 OOXML;它容易破坏命名空间和格式。
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Template Adaptation
|
||||
|
||||
当源内容项少于模板槽位时,删除多余的整组元素,包括图片、shape、文本框和图标,不要只清空文字。清空文字但保留视觉元素,会留下无意义的头像框、图标底座或空卡片。
|
||||
|
||||
模板槽位不等于源内容项。例如模板有 4 个成员卡片而源内容只有 3 人,应删除第 4 个成员的整组元素,而不是只删姓名。
|
||||
|
||||
### Multi-Item Content
|
||||
|
||||
多步骤、多条结论、多段说明应拆成多个段落。
|
||||
|
||||
错误做法:所有项目都塞进同一个 `<a:t>`。
|
||||
|
||||
```xml
|
||||
<a:t>Step 1: Do the first thing. Step 2: Do the second thing.</a:t>
|
||||
```
|
||||
|
||||
正确做法:每个项目使用独立 `<a:p>`,并对 header run 加粗。
|
||||
|
||||
```xml
|
||||
<a:p>
|
||||
<a:r><a:rPr b="1"/><a:t>Step 1</a:t></a:r>
|
||||
<a:r><a:t> Do the first thing.</a:t></a:r>
|
||||
</a:p>
|
||||
<a:p>
|
||||
<a:r><a:rPr b="1"/><a:t>Step 2</a:t></a:r>
|
||||
<a:r><a:t> Do the second thing.</a:t></a:r>
|
||||
</a:p>
|
||||
```
|
||||
|
||||
### Smart Quotes
|
||||
|
||||
`office/unpack.py` / `office/pack.py` 会处理智能引号。手动新增带引号的文本时,优先写 XML entity。
|
||||
|
||||
| Character | XML entity |
|
||||
|-----------|------------|
|
||||
| `“` | `“` |
|
||||
| `”` | `”` |
|
||||
| `‘` | `‘` |
|
||||
| `’` | `’` |
|
||||
|
||||
### Visual QA
|
||||
|
||||
修改内容后必须看渲染结果。尤其检查:
|
||||
|
||||
- 模板页型是否单调重复。
|
||||
- 长文本是否溢出、遮挡或异常换行。
|
||||
- 图片和图标是否缺失、变形或裁剪错误。
|
||||
- 删除内容后是否留下孤立形状。
|
||||
- 图表标签、图例、数据系列和来源说明是否仍然匹配。
|
||||
|
||||
## 交付格式
|
||||
|
||||
最终回复用户时说明:
|
||||
|
||||
- 输出 PPTX 的绝对路径。
|
||||
- 导入后的飞书 Slides 链接;如果用户明确不要导入,说明本次按用户要求未导入。
|
||||
- 源文件是否保持未修改。
|
||||
- 复用了哪些模板页型。
|
||||
- 做过哪些关键结构调整和内容替换。
|
||||
- 是否完成缩略图或全分辨率视觉 QA;如果未做,说明具体原因。
|
||||
@@ -1,95 +0,0 @@
|
||||
# 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 并截图检查,确认页序、视觉和文本没有破损。
|
||||
@@ -237,4 +237,4 @@ lark-cli slides +replace-slide --as user \
|
||||
- [xml_presentation.slide get](lark-slides-xml-presentation-slide-get.md) — 读原页拿 `block_id` / `revision_id`
|
||||
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
|
||||
- [+media-upload](lark-slides-media-upload.md) — 上传图片拿 `file_token`
|
||||
- [lark-slides-edit-workflows.md](lark-slides-replace-workflows) — 读-改-写闭环 + 决策树
|
||||
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环 + 决策树
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user