mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
11 Commits
feat/docs_
...
v1.0.60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd0758091 | ||
|
|
4a5a669b1a | ||
|
|
ebb0b6fe73 | ||
|
|
5c0a36b2a6 | ||
|
|
21905b0ba1 | ||
|
|
602c788fd9 | ||
|
|
30b28cf17f | ||
|
|
297776ea66 | ||
|
|
5b0c3137e3 | ||
|
|
4c31323de1 | ||
|
|
8a268aa2d2 |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,40 @@
|
||||
|
||||
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
|
||||
@@ -1265,6 +1299,8 @@ 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
|
||||
|
||||
49
affordance/README.md
Normal file
49
affordance/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
19
affordance/contact.md
Normal file
19
affordance/contact.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
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),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
|
||||
@@ -170,6 +170,10 @@ 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
|
||||
@@ -205,6 +209,8 @@ 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,10 +10,22 @@ 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"})
|
||||
|
||||
@@ -27,6 +39,8 @@ 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)
|
||||
@@ -57,9 +71,15 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
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" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
@@ -69,4 +89,12 @@ 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,6 +124,45 @@ 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,9 +11,11 @@ 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"
|
||||
@@ -28,43 +30,60 @@ import (
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>
|
||||
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).
|
||||
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
lark-cli calendar +agenda
|
||||
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`
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
// 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}}
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli -g -y
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -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}}
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
Issues: https://github.com/larksuite/cli/issues
|
||||
Docs: https://open.feishu.cn/document/
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
More help: lark-cli <command> --help`
|
||||
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}}
|
||||
`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
@@ -529,6 +548,49 @@ 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
|
||||
@@ -610,6 +672,17 @@ 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,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
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)
|
||||
// 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#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,41 +4,211 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
var sections []string
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
@@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string {
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
var s strings.Builder
|
||||
fmt.Fprintf(&s, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
fmt.Fprintf(&s, " • %s\n", it)
|
||||
}
|
||||
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Avoid when", a.AvoidWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
bullets("Tips", a.Tips)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
@@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string {
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
for _, ext := range a.Extensions {
|
||||
bullets(ext.Label, ext.Items)
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ 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": ["发送文本消息"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"avoid_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"tips": ["富文本用 msg_type=post"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
@@ -29,6 +32,7 @@ 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",
|
||||
@@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_AffordanceInLong(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) {
|
||||
withAff := map[string]interface{}{
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
@@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
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)
|
||||
if strings.Contains(cmd.Long, "Examples:") {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,11 @@ 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)
|
||||
}
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\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, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
|
||||
@@ -30,6 +30,11 @@ 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))
|
||||
}
|
||||
@@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string {
|
||||
return facts
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
return strings.Join(fieldFacts(f), ". ")
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
@@ -103,8 +103,23 @@ 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"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
// 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
|
||||
}
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -64,15 +65,38 @@ 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).
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
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 {
|
||||
resCmd := svcCmd
|
||||
var path []string
|
||||
for _, seg := range ref.ResourcePath {
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
path = append(path, seg)
|
||||
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
||||
}
|
||||
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 {
|
||||
@@ -177,7 +201,19 @@ type methodCommandSpec struct {
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
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
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
@@ -186,6 +222,7 @@ 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(),
|
||||
@@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
affordance: renderAffordance(m),
|
||||
paginates: methodPaginates(m),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +291,14 @@ 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")
|
||||
@@ -271,10 +316,11 @@ 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)
|
||||
// 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())
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
@@ -292,13 +338,11 @@ 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})
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
// 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.
|
||||
if len(spec.params) > 0 {
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
|
||||
41
content_embed.go
Normal file
41
content_embed.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
62
events/vc/participant_meeting_joined.go
Normal file
62
events/vc/participant_meeting_joined.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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)
|
||||
}
|
||||
281
events/vc/participant_meeting_lifecycle_test.go
Normal file
281
events/vc/participant_meeting_lifecycle_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
// 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
|
||||
}
|
||||
61
events/vc/participant_meeting_started.go
Normal file
61
events/vc/participant_meeting_started.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// 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,6 +11,8 @@ 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"
|
||||
@@ -30,6 +32,38 @@ 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",
|
||||
|
||||
96
internal/affordance/affordance.go
Normal file
96
internal/affordance/affordance.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// 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, " ", ".")
|
||||
}
|
||||
}
|
||||
86
internal/affordance/affordance_test.go
Normal file
86
internal/affordance/affordance_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
180
internal/affordance/mdparse.go
Normal file
180
internal/affordance/mdparse.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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,30 +5,39 @@ package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
type Affordance struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one few-shot example: a one-line description and a
|
||||
// ready-to-run command.
|
||||
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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".
|
||||
func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if len(m.Affordance) == 0 {
|
||||
return Affordance{}, false
|
||||
@@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
|
||||
if json.Unmarshal(m.Affordance, &a) != nil {
|
||||
return Affordance{}, false
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
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 {
|
||||
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":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
|
||||
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
|
||||
"malformed string": `"not an object"`,
|
||||
"malformed number": `42`,
|
||||
"nested type mismatch": `{"examples":"should be a list"}`,
|
||||
@@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) {
|
||||
// Populated affordance parses with all fields.
|
||||
raw := `{
|
||||
"use_when": ["需要拿到当前用户的主日历 ID"],
|
||||
"do_not_use_when": ["已知具体 calendar_id"],
|
||||
"avoid_when": ["已知具体 calendar_id"],
|
||||
"prerequisites": ["user 身份登录"],
|
||||
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
|
||||
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
|
||||
"related": ["calendars.list"]
|
||||
}`
|
||||
@@ -47,10 +48,22 @@ 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,7 +66,9 @@ func namedPlaceholderValue(value string) bool {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||
return strings.Contains(value, "cli_example") ||
|
||||
allXPlaceholder(value) ||
|
||||
conventionalNamedPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
@@ -81,6 +83,41 @@ 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,6 +4,8 @@
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -63,12 +65,15 @@ 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 isSchemaDottedIdentifier(line, match) {
|
||||
if !isJWTToken(match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||
}
|
||||
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
|
||||
if isPlaceholderBearerHeader(match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
@@ -391,10 +396,6 @@ 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", "{", "[":
|
||||
@@ -404,6 +405,40 @@ 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")
|
||||
}
|
||||
@@ -741,7 +776,12 @@ 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.ReplaceAllString(text, "<jwt-like-token>")
|
||||
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
if isJWTToken(match) {
|
||||
return "<jwt-like-token>"
|
||||
}
|
||||
return match
|
||||
})
|
||||
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(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
|
||||
got := ScanFile("testdata/publiccontent/.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("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/scanner_state.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("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
got := ScanFile("fixtures/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("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
got := ScanFile("fixtures/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("cmd/config/binder.go", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/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("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/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("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/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("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
|
||||
got := ScanFile("fixtures/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,8 +791,38 @@ 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("skills/idempotency.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"1704067200"}`,
|
||||
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -805,7 +835,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -821,7 +851,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "block_token": "boardXXXX" }`,
|
||||
`{ "resource_token": "doc_token_or_url" }`,
|
||||
`{ "token": "canonical_token" }`,
|
||||
@@ -841,7 +871,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
}, "\n")+"\n"))
|
||||
@@ -857,7 +887,7 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
||||
}
|
||||
|
||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
||||
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||
got := ScanFile("fixtures/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)
|
||||
}
|
||||
@@ -958,6 +988,19 @@ 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" +
|
||||
@@ -975,6 +1018,22 @@ 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",
|
||||
@@ -1012,6 +1071,37 @@ 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,8 +4,11 @@
|
||||
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"
|
||||
@@ -22,7 +25,7 @@ func Convert(f meta.Field) Property {
|
||||
if f.Type == "file" {
|
||||
p.Format = "binary"
|
||||
}
|
||||
p.Description = f.Description
|
||||
p.Description = normalizeDesc(f.Description)
|
||||
p.Default = f.CoercedDefault()
|
||||
p.Example = f.CoercedExample()
|
||||
p.Minimum = f.MinBound()
|
||||
@@ -52,6 +55,24 @@ 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
|
||||
@@ -86,6 +107,18 @@ 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
|
||||
@@ -108,16 +141,17 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
||||
Properties: &OrderedProps{},
|
||||
}
|
||||
|
||||
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())
|
||||
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")
|
||||
|
||||
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. Not sent to the backend.",
|
||||
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.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,20 +159,24 @@ func buildInputSchema(m meta.Method) *InputSchema {
|
||||
return is
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
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: propsOf(fields),
|
||||
Properties: props,
|
||||
})
|
||||
if len(req) > 0 {
|
||||
is.Required = append(is.Required, name)
|
||||
@@ -179,7 +217,13 @@ 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 {
|
||||
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
|
||||
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)
|
||||
}
|
||||
|
||||
// Envelopes renders the given method refs into envelopes, sorted by name. The
|
||||
@@ -205,7 +249,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
|
||||
|
||||
return Envelope{
|
||||
Name: name,
|
||||
Description: m.Description,
|
||||
Description: normalizeDesc(m.Description),
|
||||
InputSchema: buildInputSchema(m),
|
||||
OutputSchema: buildOutputSchema(m),
|
||||
Meta: buildMeta(m),
|
||||
|
||||
@@ -9,7 +9,9 @@ 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"
|
||||
@@ -504,6 +506,31 @@ 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,6 +13,10 @@ 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"`
|
||||
@@ -44,9 +48,15 @@ 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"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
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"`
|
||||
// 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,6 +16,14 @@ 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.
|
||||
@@ -73,6 +81,11 @@ 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
|
||||
@@ -88,7 +101,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.\n",
|
||||
key, redactProxyURL(val), EnvNoProxy)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +93,47 @@ 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.58",
|
||||
"version": "1.0.60",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFileSync, execFile } = require("child_process");
|
||||
const p = require("@clack/prompts");
|
||||
|
||||
// @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 PKG = "@larksuite/cli";
|
||||
const SKILLS_REPO = "https://open.feishu.cn";
|
||||
@@ -374,7 +379,12 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
p.cancel("Unexpected error: " + (err.message || err));
|
||||
(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);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -805,20 +806,48 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
|
||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
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("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"), "")
|
||||
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()
|
||||
|
||||
@@ -833,17 +862,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", "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("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("offset", "", "")
|
||||
cmd.Flags().String("limit", "", "")
|
||||
if apiVersion != "" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -14,7 +15,25 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const sheetImageParentType = "sheet_image"
|
||||
// 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
|
||||
}
|
||||
|
||||
var SheetMediaUpload = common.Shortcut{
|
||||
Service: "sheets",
|
||||
@@ -49,7 +68,7 @@ var SheetMediaUpload = common.Shortcut{
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_type": sheetMediaParentType(parentNode),
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
@@ -71,7 +90,7 @@ var SheetMediaUpload = common.Shortcut{
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": sheetImageParentType,
|
||||
"parent_type": sheetMediaParentType(parentNode),
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
@@ -141,13 +160,14 @@ 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: sheetImageParentType,
|
||||
ParentType: parentType,
|
||||
ParentNode: &pn,
|
||||
})
|
||||
}
|
||||
@@ -155,7 +175,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: sheetImageParentType,
|
||||
ParentType: parentType,
|
||||
ParentNode: parentNode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,6 +91,39 @@ 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)
|
||||
@@ -205,6 +238,47 @@ 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,6 +50,42 @@ 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=sheet_image)").
|
||||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": floatImageName(runtime),
|
||||
"parent_type": "sheet_image",
|
||||
"parent_type": sheetMediaParentType(token),
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + img,
|
||||
@@ -918,13 +918,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st
|
||||
if err != nil {
|
||||
return "", sheetsInputStatError("image", err)
|
||||
}
|
||||
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: img,
|
||||
FileName: floatImageName(runtime),
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &spreadsheetToken,
|
||||
})
|
||||
return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size())
|
||||
}
|
||||
|
||||
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=sheet_image)").
|
||||
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
|
||||
Body(map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "sheet_image",
|
||||
"parent_type": sheetMediaParentType(token),
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + imgPath,
|
||||
@@ -832,13 +832,7 @@ var CellsSetImage = common.Shortcut{
|
||||
WithParam("--image").
|
||||
WithCause(err)
|
||||
}
|
||||
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: imgPath,
|
||||
FileName: fileName,
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &token,
|
||||
})
|
||||
fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -496,6 +496,31 @@ 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{
|
||||
|
||||
192
shortcuts/sheets/sheet_media_parent_type_test.go
Normal file
192
shortcuts/sheets/sheet_media_parent_type_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
@@ -8,10 +8,6 @@ metadata:
|
||||
cliHelp: "lark-cli contact --help"
|
||||
---
|
||||
|
||||
# contact (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# +search-user
|
||||
|
||||
仅 user 身份。需要 scope `contact:user:search`。
|
||||
仅支持 user 身份。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- ✅ 已知姓名 / 邮箱 / 「聊过的人」想找出 open_id
|
||||
- ✅ 已知一组 open_id 想批量校验或回填字段(`--user-ids`,最多 100,支持 `me`)
|
||||
- ✅ 按聊天关系 / 在职状态 / 租户边界 / 企业邮箱等维度筛选员工
|
||||
- ❌ 已知 open_id 想拿完整 profile → 用 `+get-user --as bot`
|
||||
- ❌ 已知 open_id 想发消息 → 直接走 `lark-im`,不经过本命令
|
||||
|
||||
## 关键 flag
|
||||
|
||||
@@ -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 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 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."
|
||||
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 2 VC EventKeys (`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 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) |
|
||||
| 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,48 +2,60 @@
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (2)
|
||||
## Key catalog (4)
|
||||
|
||||
| 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.) |
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## `vc.meeting.participant_meeting_ended_v1`
|
||||
## Meeting participant events
|
||||
|
||||
Covered keys:
|
||||
|
||||
- `vc.meeting.participant_meeting_started_v1`
|
||||
- `vc.meeting.participant_meeting_joined_v1`
|
||||
- `vc.meeting.participant_meeting_ended_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
|
||||
| `type` | string | Event type; one of the covered meeting participant EventKeys |
|
||||
| `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.
|
||||
- `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`.
|
||||
- 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 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."
|
||||
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."
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
@@ -23,6 +23,27 @@ 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 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` 切换:
|
||||
@@ -108,19 +129,22 @@ lark-cli auth login --device-code <device_code>
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
除非用户正在询问更新、版本或 notice,否则不要把 `_notice` 原样复制为当前任务的主要答案,也不要为了 notice 中断当前任务去反复查 help。
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
需要稳定 JSON 给脚本或机器读取时,可以在命令前设置:
|
||||
|
||||
```bash
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 <lark-cli command>
|
||||
```
|
||||
|
||||
当你在输出中看到 `_notice.update` 时,先完成用户当前请求;如仍相关,再简短告知可运行:
|
||||
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
|
||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
|
||||
@@ -1,35 +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"
|
||||
)
|
||||
|
||||
// skillsEmbedFS embeds each skill's agent-readable content (SKILL.md +
|
||||
// references/, plus lark-whiteboard's routes/ and scenes/) so the CLI serves
|
||||
// content matching the binary version; machine-resource dirs (assets/, scripts/)
|
||||
// are excluded, saving ~3.3 MB. It's a whitelist — a new subdirectory type is
|
||||
// silently omitted until added here.
|
||||
//
|
||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes
|
||||
var skillsEmbedFS embed.FS
|
||||
|
||||
// init wires the embedded tree in as the default skill content. It compiles into
|
||||
// `go build .` but not the single-file preview build (`go build ./main.go`), so
|
||||
// main.go stays self-contained and that build still compiles (shipping no
|
||||
// embedded skills). Assembly failure warns on stderr rather than panicking.
|
||||
func init() {
|
||||
sub, err := fs.Sub(skillsEmbedFS, "skills")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
||||
return
|
||||
}
|
||||
cmd.SetEmbeddedSkillContent(sub)
|
||||
}
|
||||
112
tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go
Normal file
112
tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets
|
||||
// image-upload shortcuts emit in --dry-run output for native vs. imported
|
||||
// "office" spreadsheets. For native tokens parent_type must be "sheet_image";
|
||||
// for tokens prefixed with "fake_office_" (the synthetic token an imported
|
||||
// office spreadsheet carries) the backend requires "office_sheet_file". The
|
||||
// three covered entries — sheets +media-upload (backward), sheets
|
||||
// +cells-set-image, and sheets +create-float-image — are every image-upload
|
||||
// surface that the office/native split fans out to.
|
||||
func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "img.png"), []byte("png-bytes"), 0o600))
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
args []string
|
||||
token string
|
||||
wantParentType string
|
||||
}
|
||||
tests := []tc{
|
||||
{
|
||||
name: "media-upload native",
|
||||
args: []string{
|
||||
"sheets", "+media-upload",
|
||||
"--spreadsheet-token", "shtDryRunNative",
|
||||
"--file", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "shtDryRunNative",
|
||||
wantParentType: "sheet_image",
|
||||
},
|
||||
{
|
||||
name: "media-upload office",
|
||||
args: []string{
|
||||
"sheets", "+media-upload",
|
||||
"--spreadsheet-token", "fake_office_dryrun",
|
||||
"--file", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "fake_office_dryrun",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
{
|
||||
name: "cells-set-image native",
|
||||
args: []string{
|
||||
"sheets", "+cells-set-image",
|
||||
"--spreadsheet-token", "shtDryRunNative",
|
||||
"--sheet-id", "sheet1",
|
||||
"--range", "A1",
|
||||
"--image", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "shtDryRunNative",
|
||||
wantParentType: "sheet_image",
|
||||
},
|
||||
{
|
||||
name: "cells-set-image office",
|
||||
args: []string{
|
||||
"sheets", "+cells-set-image",
|
||||
"--spreadsheet-token", "fake_office_dryrun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--range", "A1",
|
||||
"--image", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "fake_office_dryrun",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "api.0 must be the drive upload; stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/upload_all",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantParentType, gjson.Get(out, "api.0.body.parent_type").String(),
|
||||
"parent_type for token %q must be %q; stdout:\n%s", tt.token, tt.wantParentType, out)
|
||||
require.Equal(t, tt.token, gjson.Get(out, "api.0.body.parent_node").String(),
|
||||
"parent_node must equal the spreadsheet token; stdout:\n%s", out)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user