mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
16 Commits
docs/drive
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
823a55a1ef | ||
|
|
e98471ce26 | ||
|
|
9e2be14301 | ||
|
|
367cfc9d06 | ||
|
|
e182b01f68 | ||
|
|
1135fc2767 | ||
|
|
68d78d5067 | ||
|
|
b783561965 | ||
|
|
f00261da9f | ||
|
|
137176e8b0 | ||
|
|
0bf590d01a | ||
|
|
cf40945bbc | ||
|
|
b9e5b50251 | ||
|
|
049ddf771b | ||
|
|
f12d279fc2 | ||
|
|
83adbac2b2 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -9,7 +9,7 @@
|
||||
## Test Plan
|
||||
<!-- Describe how this change was verified. -->
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Manual local verification confirms the `lark xxx` command works as expected
|
||||
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
|
||||
|
||||
## Related Issues
|
||||
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli
|
||||
/lark-cli*
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.41] - 2026-05-26
|
||||
|
||||
### Features
|
||||
|
||||
- **minutes**: Add minutes edit shortcuts (#1036)
|
||||
- **minutes**: Get minutes keywords (#1079)
|
||||
- **slides**: Support importing pptx as slides (#1068)
|
||||
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
|
||||
- **errors**: Add structured CLI error contract (#984)
|
||||
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Support doubao drive inspect URL variants (#1106)
|
||||
- **skills**: Sync skills incrementally during update (#1042)
|
||||
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
|
||||
- **common**: Escape special chars in multipart form filenames (#1037)
|
||||
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Fix agent routing for doubao.com URLs (#1082)
|
||||
- **task**: Require `--complete=false` for pending standup summaries (#1101)
|
||||
- **base**: Document UI-only field settings (#1078)
|
||||
- **contributing**: Clarify contributor guidance (#1096)
|
||||
|
||||
## [v1.0.40] - 2026-05-25
|
||||
|
||||
### Features
|
||||
@@ -860,6 +886,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
|
||||
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
|
||||
|
||||
@@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio
|
||||
|
||||
For major changes, we recommend discussing with us first via an Issue.
|
||||
|
||||
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**.
|
||||
|
||||
@@ -280,6 +280,8 @@ lark-cli schema im.messages.delete
|
||||
|
||||
对于较大的改动,建议先通过 Issue 与我们讨论。
|
||||
|
||||
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 **MIT 许可证** 开源。
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
73
cmd/config/keychain_downgrade.go
Normal file
73
cmd/config/keychain_downgrade.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
|
||||
// the master key to the local file fallback (master.key.file) so subsequent
|
||||
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
|
||||
// where the system Keychain is unreachable.
|
||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "keychain-downgrade",
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Materialize the master key from the macOS system Keychain into a local file
|
||||
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
|
||||
subsequent reads to that file.
|
||||
|
||||
Intended workflow: run this once from an interactive Terminal session on
|
||||
macOS (where the system Keychain is reachable). After it finishes,
|
||||
sandboxed / automation / CI runs of lark-cli on the same machine will read
|
||||
the master key from the local file and no longer need the OS Keychain.
|
||||
|
||||
This is the supported fix for environments like the Codex sandbox where the
|
||||
system Keychain is blocked. Running keychain-downgrade from inside such a
|
||||
sandbox will itself fail with "keychain access blocked" — that is expected;
|
||||
run it from an interactive macOS session instead.
|
||||
|
||||
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
|
||||
The command is idempotent: re-running it on an already-downgraded install
|
||||
reports "already downgraded" and exits 0.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configKeychainDowngradeRun(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
||||
service := keychain.LarkCliService
|
||||
keyPath := keychain.MasterKeyFilePath(service)
|
||||
|
||||
result, err := keychain.DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitAPI,
|
||||
"config",
|
||||
fmt.Sprintf("keychain downgrade failed: %v", err),
|
||||
"This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.",
|
||||
)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case keychain.DowngradeAlreadyDone:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
|
||||
case keychain.DowngradeUsedKeychainKey:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
|
||||
case keychain.DowngradeCreatedNewKey:
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
cmd/config/keychain_downgrade_other.go
Normal file
28
cmd/config/keychain_downgrade_other.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
|
||||
// `lark-cli config --help` reads the same everywhere. On non-macOS it
|
||||
// refuses with a clear message.
|
||||
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
_ = f
|
||||
cmd := &cobra.Command{
|
||||
Use: "keychain-downgrade",
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.ErrValidation("keychain-downgrade is only supported on macOS")
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -384,11 +384,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
|
||||
// produces no skills key in the composed notice. Users who installed
|
||||
// skills via `npx skills add` (no stamp) must not see the misleading
|
||||
// "not installed" notice — only `lark-cli update` users opt into the
|
||||
// drift tracker.
|
||||
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
||||
// produces no skills key in the composed notice.
|
||||
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
@@ -419,13 +416,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_InSync verifies that a matching stamp produces no
|
||||
// TestSetupNotices_InSync verifies that matching state produces no
|
||||
// skills key in the composed notice.
|
||||
func TestSetupNotices_InSync(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -452,13 +449,13 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_Drift verifies a mismatching stamp produces the
|
||||
// TestSetupNotices_Drift verifies mismatching state produces the
|
||||
// drift message with both current and target populated.
|
||||
func TestSetupNotices_Drift(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -507,7 +504,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
clearNoticeEnv(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -24,7 +25,8 @@ type SchemaOptions struct {
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
@@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
opts := &SchemaOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Use: "schema [path | service resource method]",
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
return nil
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
|
||||
parts := strings.Split(opts.Path, ".")
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", serviceName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec, mode)
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var resNames []string
|
||||
var names []string
|
||||
for k := range resources {
|
||||
resNames = append(resNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var mNames []string
|
||||
var names []string
|
||||
for k := range methods {
|
||||
mNames = append(mNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
} else {
|
||||
output.PrintJson(out, method)
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
|
||||
@@ -5,6 +5,7 @@ package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs(t *testing.T) {
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list output")
|
||||
t.Error("expected service list in pretty mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(out, "[") {
|
||||
head := out
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
meta, _ := env["_meta"].(map[string]interface{})
|
||||
if meta["envelope_version"] != "1.0" {
|
||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd1 := NewCmdSchema(f1, nil)
|
||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
||||
if err := cmd1.Execute(); err != nil {
|
||||
t.Fatalf("space form failed: %v", err)
|
||||
}
|
||||
|
||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd2 := NewCmdSchema(f2, nil)
|
||||
cmd2.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd2.Execute(); err != nil {
|
||||
t.Fatalf("dotted form failed: %v", err)
|
||||
}
|
||||
|
||||
if out1.String() != out2.String() {
|
||||
t.Errorf("space and dotted forms produced different output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty array for service im")
|
||||
}
|
||||
for _, e := range envs {
|
||||
name, _ := e["name"].(string)
|
||||
if !strings.HasPrefix(name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.messages.delete"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; !ok {
|
||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.reactions.list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; ok {
|
||||
t.Errorf("yes property should not appear for risk=read command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,15 +31,18 @@ var (
|
||||
currentVersion = func() string { return build.Version }
|
||||
currentOS = runtime.GOOS
|
||||
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
|
||||
)
|
||||
|
||||
func isWindows() bool { return currentOS == osWindows }
|
||||
|
||||
// normalizeVersion canonicalizes a version string for stamp comparison.
|
||||
// normalizeVersion canonicalizes a version string for state comparison.
|
||||
// Strips a leading "v" so versions written from Makefile (git describe →
|
||||
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
|
||||
func normalizeVersion(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
return strings.TrimPrefix(s, "V")
|
||||
}
|
||||
|
||||
func releaseURL(version string) string {
|
||||
@@ -121,7 +124,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
updater.CleanupStaleFiles()
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
}
|
||||
output.PendingNotice = nil
|
||||
|
||||
// 1. Fetch latest version
|
||||
@@ -137,13 +142,9 @@ func updateRun(opts *UpdateOptions) error {
|
||||
|
||||
// 3. Compare versions
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
// Run skills sync before returning — covers the case where the
|
||||
// binary is already current but skills were never synced.
|
||||
// Stamp dedup makes this a no-op if skills are already in sync.
|
||||
// Skip side-effects under --check (pure report path per spec §3.6).
|
||||
var skillsResult *selfupdate.NpmResult
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -185,16 +186,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
|
||||
"url": releaseURL(latest), "changelog": changelogURL(),
|
||||
}
|
||||
// skills_status: pure report, no side effect, no stamp write.
|
||||
// ReadStamp errors are silently swallowed — if we can't read the
|
||||
// stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
output.PrintJson(io.Out, out)
|
||||
return nil
|
||||
}
|
||||
@@ -210,7 +202,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -288,10 +280,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
// Skills update (best-effort) — uses runSkillsAndStamp so the
|
||||
// stamp gets persisted on success and dedup applies if a previous
|
||||
// run already stamped this version.
|
||||
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -328,27 +317,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
// stamp on success. Skips the npx invocation when the stamp already
|
||||
// matches stampVersion (unless force is true). The stamp write failure
|
||||
// emits a warning to io.ErrOut but does NOT fail the update command —
|
||||
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
|
||||
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
|
||||
// dedup; otherwise returns the underlying *NpmResult with Err semantics
|
||||
// from RunSkillsUpdate.
|
||||
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
r := updater.RunSkillsUpdate()
|
||||
if r.Err == nil {
|
||||
if err := skillscheck.WriteStamp(stampVersion); err != nil {
|
||||
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
}
|
||||
return r
|
||||
return result
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
@@ -356,7 +339,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
// report path (spec §3.6): no side-effects, JSON envelope uses
|
||||
// skills_status (spec §4.2) instead of skills_action.
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
|
||||
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
|
||||
if opts.JSON {
|
||||
out := map[string]interface{}{
|
||||
"ok": true, "previous_version": cur, "current_version": cur,
|
||||
@@ -364,16 +347,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
|
||||
}
|
||||
if check {
|
||||
// Pure report — read stamp directly, emit skills_status block.
|
||||
// ReadStamp errors are silently swallowed — if we can't read
|
||||
// the stamp we just omit the block rather than fail the --check.
|
||||
if stamp, err := skillscheck.ReadStamp(); err == nil {
|
||||
out["skills_status"] = map[string]interface{}{
|
||||
"current": stamp,
|
||||
"target": cur,
|
||||
"in_sync": stamp == cur,
|
||||
}
|
||||
}
|
||||
applySkillsStatus(out, cur)
|
||||
} else {
|
||||
applySkillsResult(out, skillsResult)
|
||||
}
|
||||
@@ -387,36 +361,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySkillsResult mutates the JSON envelope to include skills_action
|
||||
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
|
||||
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
|
||||
func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return
|
||||
}
|
||||
status := map[string]interface{}{
|
||||
"current": state.Version,
|
||||
"target": target,
|
||||
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
|
||||
}
|
||||
if len(state.OfficialSkills) > 0 {
|
||||
status["official"] = len(state.OfficialSkills)
|
||||
}
|
||||
if len(state.UpdatedSkills) > 0 {
|
||||
status["updated"] = len(state.UpdatedSkills)
|
||||
}
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
env["skills_action"] = "in_sync"
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
|
||||
}
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
}
|
||||
}
|
||||
|
||||
// emitSkillsTextHints prints human-readable feedback about the skills
|
||||
// sync result for non-JSON output.
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
|
||||
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
summary := map[string]interface{}{
|
||||
"official": len(r.Official),
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
switch {
|
||||
case r == nil:
|
||||
// dedup hit — silent (already up to date)
|
||||
case r.Err != nil:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
|
||||
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ package cmdupdate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -28,7 +29,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
@@ -41,22 +41,53 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
}
|
||||
|
||||
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
|
||||
npmFn func(string) *selfupdate.NpmResult,
|
||||
skillsFn func() *selfupdate.NpmResult) {
|
||||
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.SkillsUpdateOverride = skillsFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
switch strings.Join(args, " ") {
|
||||
case "-y skills add https://open.feishu.cn --list":
|
||||
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
||||
case "-y skills ls -g":
|
||||
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
||||
default:
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{input: "1.2.3", want: "1.2.3"},
|
||||
{input: "v1.2.3", want: "1.2.3"},
|
||||
{input: "V1.2.3", want: "1.2.3"},
|
||||
{input: " v1.2.3 ", want: "1.2.3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := normalizeVersion(tt.input); got != tt.want {
|
||||
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
@@ -168,9 +199,7 @@ func TestUpdateManual_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateNpm_JSON(t *testing.T) {
|
||||
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
|
||||
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
|
||||
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
|
||||
// Isolate config dir because skills sync writes skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -186,7 +215,6 @@ func TestUpdateNpm_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -216,7 +244,6 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -230,7 +257,7 @@ func TestUpdateNpm_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateForce_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -246,7 +273,6 @@ func TestUpdateForce_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -323,7 +349,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -339,7 +365,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -451,8 +476,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills update should not run when binary verification fails")
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
@@ -649,7 +674,7 @@ func TestPermissionHint(t *testing.T) {
|
||||
|
||||
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
// With the rename trick, Windows npm installs can now auto-update.
|
||||
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
|
||||
// Same state-isolation rationale as TestUpdateNpm_JSON.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -668,7 +693,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -750,7 +774,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
|
||||
mockDetectAndNpm(t,
|
||||
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
|
||||
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||
)
|
||||
|
||||
err := cmd.Execute()
|
||||
@@ -785,8 +808,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
// Skills update fails
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -812,8 +834,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
if !strings.Contains(out, "skills_warning") {
|
||||
t.Errorf("expected skills_warning in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills_detail") {
|
||||
t.Errorf("expected skills_detail in output, got: %s", out)
|
||||
if !strings.Contains(out, "skills_summary") {
|
||||
t.Errorf("expected skills_summary in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +860,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
r.Err = fmt.Errorf("exit status 127")
|
||||
@@ -861,100 +883,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
if !strings.Contains(out, "Skills update failed") {
|
||||
t.Errorf("expected skills failure warning, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx -y skills add") {
|
||||
t.Errorf("expected manual skills command hint, got: %s", out)
|
||||
if !strings.Contains(out, "lark-cli update --force") {
|
||||
t.Errorf("expected force retry hint, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
|
||||
// for direct calls to internals like runSkillsAndStamp that write to
|
||||
// io.ErrOut.
|
||||
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
||||
func newTestIO() *cmdutil.IOStreams {
|
||||
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
if called {
|
||||
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
|
||||
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil {
|
||||
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsUpdateOverride not called with force=true")
|
||||
t.Error("SkillsCommandOverride not called with force=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,8 +991,7 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -987,9 +1004,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1000,17 +1017,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
|
||||
t.Error("skills sync not called in already-up-to-date branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1029,9 +1048,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
ResolvedPath: "/usr/local/bin/lark-cli",
|
||||
}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1042,17 +1061,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in manual branch, want called")
|
||||
t.Error("skills sync not called in manual branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.21" {
|
||||
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
origFetch := fetchLatest
|
||||
origCur := currentVersion
|
||||
@@ -1075,9 +1096,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
VerifyOverride: func(expectedVersion string) error { return nil },
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1088,18 +1109,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
|
||||
t.Fatalf("updateRun() err = %v, want nil", err)
|
||||
}
|
||||
if !skillsCalled {
|
||||
t.Error("RunSkillsUpdate not called in npm branch")
|
||||
t.Error("skills sync not called in npm branch")
|
||||
}
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.22" {
|
||||
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.22" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.20",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedSkills: []string{"lark-calendar"},
|
||||
SkippedDeletedSkills: []string{"lark-mail"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1117,9 +1145,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1130,7 +1158,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
|
||||
t.Error("skills sync called under --check, want skipped")
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1144,12 +1172,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
|
||||
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
|
||||
}
|
||||
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
||||
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1164,9 +1194,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return &selfupdate.NpmResult{}
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1177,12 +1207,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
|
||||
}
|
||||
if skillsCalled {
|
||||
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
|
||||
t.Error("skills sync called under --check (already-latest), want skipped")
|
||||
}
|
||||
|
||||
stamp, _ := skillscheck.ReadStamp()
|
||||
if stamp != "1.0.20" {
|
||||
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
@@ -1204,39 +1237,248 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
|
||||
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
|
||||
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
|
||||
// Force WriteStamp to fail by pointing config dir at a path that exists
|
||||
// as a regular file (so MkdirAll fails).
|
||||
tmp := t.TempDir()
|
||||
badPath := filepath.Join(tmp, "blocker")
|
||||
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
origSync := syncSkills
|
||||
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
||||
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsUpdateOverride: func() *selfupdate.NpmResult {
|
||||
return &selfupdate.NpmResult{} // success
|
||||
},
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
|
||||
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
||||
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
|
||||
// message is printed to ErrOut on a successful (Err == nil) result.
|
||||
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
f, _, stderr := newTestFactory(t)
|
||||
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
|
||||
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
||||
if !strings.Contains(stderr.String(), "Skills updated") {
|
||||
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
// intentionally removed them while they were still official skills.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
before := skillscheck.SkillsState{
|
||||
Version: "1.0.19",
|
||||
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
||||
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
||||
AddedOfficialSkills: []string{},
|
||||
SkippedDeletedSkills: []string{},
|
||||
UpdatedAt: "2026-05-20T00:00:00Z",
|
||||
}
|
||||
if err := skillscheck.WriteState(before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.19" {
|
||||
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
|
||||
}
|
||||
|
||||
// Phase 3: Mock version functions so the update command believes it has
|
||||
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
|
||||
// This triggers SyncSkills which calls the real npx skills add command.
|
||||
origFetch := fetchLatest
|
||||
origVersion := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
||||
currentVersion = func() string { return "1.0.20" }
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Phase 4: Verify the state file was rewritten with the new version,
|
||||
// non-empty official/updated skill lists, and a refreshed timestamp.
|
||||
state, readable, err = skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
|
||||
}
|
||||
if len(state.OfficialSkills) == 0 {
|
||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if len(state.UpdatedSkills) == 0 {
|
||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
|
||||
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
|
||||
}
|
||||
// Verify that previously-skipped skills are handled correctly:
|
||||
// - If locally installed → should appear in UpdatedSkills (updated to latest)
|
||||
// - If locally absent → should NOT be force-restored in UpdatedSkills,
|
||||
// and should remain in SkippedDeletedSkills
|
||||
for _, skill := range []string{"lark-doc", "lark-mail"} {
|
||||
if containsString(localSkills, skill) {
|
||||
if !containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
|
||||
}
|
||||
if !containsString(state.SkippedDeletedSkills, skill) {
|
||||
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
||||
// the expected action fields for AI agent consumption.
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
||||
}
|
||||
if env["skills_action"] != "synced" {
|
||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if _, readable, _ := skillscheck.ReadState(); readable {
|
||||
t.Fatal("skills-state.json should not exist before update")
|
||||
}
|
||||
|
||||
// Phase 3: Mock version functions so the update command believes it is at
|
||||
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
|
||||
// which calls the real npx skills add command.
|
||||
origFetch := fetchLatest
|
||||
origVersion := currentVersion
|
||||
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
||||
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
||||
currentVersion = func() string { return "1.0.20" }
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Phase 4: Verify the state file was created with all official skills in
|
||||
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
|
||||
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.20" {
|
||||
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
|
||||
}
|
||||
if len(state.OfficialSkills) == 0 {
|
||||
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if len(state.UpdatedSkills) == 0 {
|
||||
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
||||
}
|
||||
if state.UpdatedAt == "" {
|
||||
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
|
||||
}
|
||||
// All locally installed official skills must appear in UpdatedSkills.
|
||||
officialSet := map[string]bool{}
|
||||
for _, s := range state.OfficialSkills {
|
||||
officialSet[s] = true
|
||||
}
|
||||
for _, skill := range localSkills {
|
||||
if !officialSet[skill] {
|
||||
continue
|
||||
}
|
||||
if !containsString(state.UpdatedSkills, skill) {
|
||||
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
|
||||
}
|
||||
}
|
||||
// No skill should be in SkippedDeletedSkills on cold start — there is no
|
||||
// previous state recording a user deletion to preserve.
|
||||
if len(state.SkippedDeletedSkills) != 0 {
|
||||
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
|
||||
}
|
||||
|
||||
// Phase 5: Verify the JSON output structure is parseable and contains
|
||||
// the expected action fields for AI agent consumption.
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env["action"] != "already_up_to_date" {
|
||||
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
||||
}
|
||||
if env["skills_action"] != "synced" {
|
||||
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
116
events/minutes/minute_generated.go
Normal file
116
events/minutes/minute_generated.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesDetailRetryDelay = 500 * time.Millisecond
|
||||
minutesDetailMaxRetries = 2
|
||||
)
|
||||
|
||||
// MinutesMinuteSourceOutput is the flattened minute source payload.
|
||||
type MinutesMinuteSourceOutput struct {
|
||||
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
|
||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
||||
}
|
||||
|
||||
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
|
||||
type MinutesMinuteGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always minutes.minute.generated_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"`
|
||||
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
|
||||
Title string `json:"title,omitempty" desc:"Minute title"`
|
||||
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
|
||||
}
|
||||
|
||||
func processMinutesMinuteGenerated(ctx context.Context, rt 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 {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
MinuteSource struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceEntityID string `json:"source_entity_id"`
|
||||
} `json:"minute_source"`
|
||||
} `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
|
||||
}
|
||||
|
||||
out := &MinutesMinuteGeneratedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MinuteToken: envelope.Event.MinuteToken,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
||||
out.MinuteSource = &MinutesMinuteSourceOutput{
|
||||
SourceType: src.SourceType,
|
||||
SourceEntityID: src.SourceEntityID,
|
||||
}
|
||||
}
|
||||
|
||||
if rt != nil && out.MinuteToken != "" {
|
||||
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
|
||||
if rt == nil || out == nil || out.MinuteToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
|
||||
|
||||
type minuteDetailResp struct {
|
||||
Data struct {
|
||||
Minute struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"minute"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(minutesDetailRetryDelay)
|
||||
}
|
||||
|
||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var resp minuteDetailResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Data.Minute.Title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out.Title = resp.Data.Minute.Title
|
||||
return
|
||||
}
|
||||
}
|
||||
353
events/minutes/minute_generated_test.go
Normal file
353
events/minutes/minute_generated_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
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] != "minutes:minutes.basic:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var gotMethod, gotPath string
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
gotMethod = method
|
||||
gotPath = path
|
||||
if body != nil {
|
||||
t.Fatalf("GET detail body = %#v, want nil", body)
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"token": "<doc_token_001>",
|
||||
"title": "产品周会的视频会议",
|
||||
"note_id": "7616590025794260496"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_001",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_001>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "6911188411934433028"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if gotMethod != "GET" {
|
||||
t.Errorf("detail method = %q, want GET", gotMethod)
|
||||
}
|
||||
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
|
||||
t.Errorf("detail path = %q", gotPath)
|
||||
}
|
||||
if out.Type != eventTypeMinuteGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_001>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "产品周会的视频会议" {
|
||||
t.Errorf("Title = %q", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should not be nil")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
called++
|
||||
return nil, context.DeadlineExceeded
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_002",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_004>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "7641156270787481117"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_004>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should remain from event payload")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
||||
called++
|
||||
if called <= 1 {
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": "delayed title"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_retry",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_003>"
|
||||
}
|
||||
}`)
|
||||
|
||||
if called != 2 {
|
||||
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
|
||||
}
|
||||
if out.Title != "delayed title" {
|
||||
t.Errorf("Title = %q, want delayed title", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
||||
called++
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_exhaust",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_002>"
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
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 != pathMinuteSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(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 runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out MinutesMinuteGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/minutes/preconsume.go
Normal file
33
events/minutes/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
42
events/minutes/register.go
Normal file
42
events/minutes/register.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package minutes registers Minutes-domain EventKeys.
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
|
||||
|
||||
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
|
||||
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
|
||||
|
||||
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
|
||||
)
|
||||
|
||||
// Keys returns all Minutes-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMinuteGenerated,
|
||||
DisplayName: "Minute generated",
|
||||
Description: "Triggered when a minute has been generated",
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
|
||||
},
|
||||
Process: processMinutesMinuteGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
|
||||
Scopes: []string{"minutes:minutes.basic:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@ package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
77
events/vc/participant_meeting_ended.go
Normal file
77
events/vc/participant_meeting_ended.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
|
||||
type VCParticipantMeetingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_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"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingEnded(_ 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 := &VCParticipantMeetingEndedOutput{
|
||||
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),
|
||||
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func unixSecondsToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
203
events/vc/participant_meeting_ended_test.go
Normal file
203
events/vc/participant_meeting_ended_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMeetingEnded)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_001",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"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 := runMeetingEnded(t, payload)
|
||||
|
||||
if out.Type != eventTypeMeetingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_vc_end_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1608725989000" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.MeetingID != "6911188411934433028" {
|
||||
t.Errorf("MeetingID = %q", out.MeetingID)
|
||||
}
|
||||
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
|
||||
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
|
||||
}
|
||||
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
|
||||
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
|
||||
}
|
||||
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
|
||||
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_002",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
if out.StartTime != "" || out.EndTime != "" {
|
||||
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(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 TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
|
||||
if !ok {
|
||||
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
|
||||
}
|
||||
|
||||
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, eventTypeMeetingEnded)
|
||||
|
||||
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, eventTypeMeetingEnded)
|
||||
}
|
||||
|
||||
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out VCParticipantMeetingEndedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/vc/preconsume.go
Normal file
33
events/vc/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
43
events/vc/register.go
Normal file
43
events/vc/register.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package vc registers VC-domain EventKeys.
|
||||
package vc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
)
|
||||
|
||||
// Keys returns all VC-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
Description: "Triggered when a meeting the current user participates in has ended",
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
events/vc/test_helpers_test.go
Normal file
30
events/vc/test_helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func RequireConfirmation(action string) error {
|
||||
Message: fmt.Sprintf("%s requires confirmation", action),
|
||||
Hint: "add --yes to confirm",
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "high-risk-write",
|
||||
Level: RiskHighRiskWrite,
|
||||
Action: action,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,11 +7,20 @@ import "github.com/spf13/cobra"
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// Risk level constants — the three-tier convention used across the CLI.
|
||||
// Use these in place of string literals so the typo radius is one place,
|
||||
// not every call site.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
// help renderer (cmd/root.go) can surface a Risk: line without importing
|
||||
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
|
||||
// | "high-risk-write". Framework-level confirmation gating only acts on
|
||||
// "high-risk-write".
|
||||
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
|
||||
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
|
||||
// acts on RiskHighRiskWrite.
|
||||
func SetRisk(cmd *cobra.Command, level string) {
|
||||
if level == "" {
|
||||
return
|
||||
|
||||
@@ -41,6 +41,7 @@ func wrapError(op string, err error) error {
|
||||
if errors.Is(err, errNotInitialized) {
|
||||
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
|
||||
}
|
||||
hint += extraHint(err)
|
||||
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
|
||||
@@ -43,6 +43,12 @@ var keyringGet = keyring.Get
|
||||
// keyringSet is overridden in tests to simulate system keychain writes.
|
||||
var keyringSet = keyring.Set
|
||||
|
||||
// errKeychainBlocked is returned when the OS Keychain is reachable but
|
||||
// denies access — sandbox restriction, user-denied prompt, or a 5-second
|
||||
// timeout (typically caused by an ignored permission dialog). Distinct
|
||||
// from errNotInitialized (master key entry genuinely absent).
|
||||
var errKeychainBlocked = errors.New("keychain access blocked")
|
||||
|
||||
// StorageDir returns the storage directory for a given service name on macOS.
|
||||
func StorageDir(service string) string {
|
||||
home, err := vfs.UserHomeDir()
|
||||
@@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
return
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
// Not ErrNotFound, which means access was denied or blocked by the system
|
||||
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
|
||||
resCh <- result{key: nil, err: errKeychainBlocked}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
// Timeout is usually caused by ignored/blocked permission prompts
|
||||
return nil, errors.New("keychain access blocked")
|
||||
return nil, errKeychainBlocked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plaintext, nil
|
||||
return decryptData(data, key)
|
||||
}
|
||||
|
||||
// platformSet stores a value in the macOS keychain.
|
||||
@@ -316,3 +318,116 @@ func platformRemove(service, account string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DowngradeResult reports what DowngradeMasterKeyToFile did. The command
|
||||
// never writes to or removes from the OS Keychain — it only reads from it
|
||||
// and only writes to the local file fallback.
|
||||
type DowngradeResult int
|
||||
|
||||
const (
|
||||
// DowngradeAlreadyDone means master.key.file was already present and valid.
|
||||
DowngradeAlreadyDone DowngradeResult = iota
|
||||
// DowngradeUsedKeychainKey means the existing OS Keychain master key was
|
||||
// copied verbatim into the local file fallback. Existing .enc credentials
|
||||
// remain readable via the file path.
|
||||
DowngradeUsedKeychainKey
|
||||
// DowngradeCreatedNewKey means the OS Keychain held no master key, so a
|
||||
// fresh random key was generated and written to the file fallback only.
|
||||
// The OS Keychain was not touched.
|
||||
DowngradeCreatedNewKey
|
||||
)
|
||||
|
||||
// MasterKeyFilePath returns the absolute path of the file fallback master key
|
||||
// for the given service.
|
||||
func MasterKeyFilePath(service string) string {
|
||||
return filepath.Join(StorageDir(service), fileMasterKeyName)
|
||||
}
|
||||
|
||||
// DowngradeMasterKeyToFile materializes the OS Keychain master key into the
|
||||
// local file fallback so that subsequent platformGet calls take the file-first
|
||||
// path and bypass the OS Keychain entirely. The Keychain entry itself is kept
|
||||
// as a cold backup; nothing is removed there.
|
||||
//
|
||||
// Idempotent: if master.key.file is already present and valid, returns
|
||||
// DowngradeAlreadyDone without touching anything.
|
||||
func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, fileMasterKeyName)
|
||||
|
||||
existing, statErr := vfs.ReadFile(keyPath)
|
||||
if statErr == nil {
|
||||
if len(existing) == masterKeyBytes {
|
||||
return DowngradeAlreadyDone, nil
|
||||
}
|
||||
return 0, errors.New("keychain is corrupted")
|
||||
}
|
||||
if !errors.Is(statErr, os.ErrNotExist) {
|
||||
return 0, statErr
|
||||
}
|
||||
|
||||
result := DowngradeUsedKeychainKey
|
||||
key, err := getMasterKey(service, false)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errNotInitialized) {
|
||||
return 0, err
|
||||
}
|
||||
// Keychain has no master key. Generate a fresh one *locally* — do
|
||||
// NOT call getMasterKey(service, true), which would write the new
|
||||
// key into the OS Keychain as a side effect. keychain-downgrade
|
||||
// must never modify the OS Keychain; it only ever reads from it.
|
||||
key = make([]byte, masterKeyBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
result = DowngradeCreatedNewKey
|
||||
}
|
||||
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
concurrent, readErr := vfs.ReadFile(keyPath)
|
||||
if readErr == nil && len(concurrent) == masterKeyBytes {
|
||||
return DowngradeAlreadyDone, nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return 0, readErr
|
||||
}
|
||||
return 0, errors.New("keychain is corrupted")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
writeFailed := true
|
||||
defer func() {
|
||||
if writeFailed {
|
||||
_ = vfs.Remove(keyPath)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(key); err != nil {
|
||||
_ = file.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
writeFailed = false
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extraHint appends a darwin-specific suggestion to wrapError's hint message
|
||||
// when the failure is one keychain-downgrade can recover from: either the
|
||||
// master key is missing (errNotInitialized) or the OS Keychain is reachable
|
||||
// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout).
|
||||
// In both cases the user can run keychain-downgrade from an interactive
|
||||
// Terminal session, after which the file fallback is readable from any
|
||||
// context (sandbox, automation, CI, etc.). Corruption errors are
|
||||
// deliberately excluded — downgrade would re-read the same bad bytes and
|
||||
// fail; the right fix there is to delete the corrupt Keychain entry first.
|
||||
func extraHint(err error) string {
|
||||
if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) {
|
||||
return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
@@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade
|
||||
// when master.key.file already exists is a no-op and reports AlreadyDone
|
||||
// without touching the system keychain.
|
||||
func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
t.Fatalf("keyringGet should not be called when master.key.file is already valid")
|
||||
return "", nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet should not be called when master.key.file is already valid")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
preExisting := make([]byte, masterKeyBytes)
|
||||
for i := range preExisting {
|
||||
preExisting[i] = byte(i + 7)
|
||||
}
|
||||
keyPath := filepath.Join(dir, fileMasterKeyName)
|
||||
if err := os.WriteFile(keyPath, preExisting, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(master key) error = %v", err)
|
||||
}
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeAlreadyDone {
|
||||
t.Fatalf("result = %v, want DowngradeAlreadyDone", result)
|
||||
}
|
||||
|
||||
after, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if !bytesEqual(after, preExisting) {
|
||||
t.Fatalf("master.key.file content changed; want preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain
|
||||
// key exists, the file does not, and downgrade copies the bytes verbatim
|
||||
// so that existing .enc files (encrypted with the keychain key) remain
|
||||
// readable via the file fallback.
|
||||
func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
keychainKey := make([]byte, masterKeyBytes)
|
||||
for i := range keychainKey {
|
||||
keychainKey[i] = byte(i + 11)
|
||||
}
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(keychainKey), nil
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet should not be called when keychain already has a master key")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeUsedKeychainKey {
|
||||
t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(MasterKeyFilePath(service))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(master.key.file) error = %v", err)
|
||||
}
|
||||
if !bytesEqual(got, keychainKey) {
|
||||
t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user"
|
||||
// path: keychain is empty and no .enc files exist, so we generate a new
|
||||
// random key and write it to the file fallback. The OS Keychain is NOT
|
||||
// modified (regression guard for the side-effecting getMasterKey(_, true)
|
||||
// call we used to make).
|
||||
func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeCreatedNewKey {
|
||||
t.Fatalf("result = %v, want DowngradeCreatedNewKey", result)
|
||||
}
|
||||
|
||||
fileKey, err := os.ReadFile(MasterKeyFilePath(service))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(master.key.file) error = %v", err)
|
||||
}
|
||||
if len(fileKey) != masterKeyBytes {
|
||||
t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard
|
||||
// for the TOCTOU between the initial existence check and the final write.
|
||||
// Race trace the fix closes:
|
||||
//
|
||||
// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes)
|
||||
// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B
|
||||
// then writes .enc encrypted with K_B
|
||||
// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc
|
||||
//
|
||||
// We simulate proc B's interleaving by performing the concurrent file write
|
||||
// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back
|
||||
// to the final OpenFile call, the file already exists, the O_EXCL branch
|
||||
// fires, and the concurrent key is preserved verbatim.
|
||||
func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
service := "test-service"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
concurrentKey := make([]byte, masterKeyBytes)
|
||||
for i := range concurrentKey {
|
||||
concurrentKey[i] = byte(i + 77)
|
||||
}
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(svc, user string) (string, error) {
|
||||
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil {
|
||||
t.Fatalf("simulated concurrent write failed: %v", err)
|
||||
}
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
keyringSet = func(svc, user, password string) error {
|
||||
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
result, err := DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
|
||||
}
|
||||
if result != DowngradeAlreadyDone {
|
||||
t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error = %v", err)
|
||||
}
|
||||
if !bytesEqual(got, concurrentKey) {
|
||||
t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked"
|
||||
// (the sandbox case) propagates as errKeychainBlocked through platformGet, so
|
||||
// the wrapError hint chain can attach the keychain-downgrade suggestion.
|
||||
func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
origGet := keyringGet
|
||||
origSet := keyringSet
|
||||
keyringGet = func(service, user string) (string, error) {
|
||||
return "", errors.New("sandbox denied keychain access")
|
||||
}
|
||||
keyringSet = func(service, user, password string) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
keyringGet = origGet
|
||||
keyringSet = origSet
|
||||
})
|
||||
|
||||
service := "test-service"
|
||||
account := "test-account"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
|
||||
lostKey := make([]byte, masterKeyBytes)
|
||||
for i := range lostKey {
|
||||
lostKey[i] = byte(i + 55)
|
||||
}
|
||||
encrypted, err := encryptData("secret", lostKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(.enc) error = %v", err)
|
||||
}
|
||||
|
||||
_, err = platformGet(service, account)
|
||||
if !errors.Is(err, errKeychainBlocked) {
|
||||
t.Fatalf("err = %v, want errKeychainBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression
|
||||
// guard for the bug where `lark-cli api ...` inside a sandbox surfaced
|
||||
// "keychain access blocked" but the hint did NOT mention keychain-downgrade
|
||||
// — the very command meant to recover from that exact situation. Root cause:
|
||||
// the blocked path used an anonymous errors.New string, so the extraHint
|
||||
// `errors.Is` check (only matched errNotInitialized) couldn't recognize it.
|
||||
//
|
||||
// Asserts the full wrapError → ExitError.Detail.Hint pipeline:
|
||||
// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade
|
||||
// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention
|
||||
// - generic errors → no mention
|
||||
//
|
||||
// Add new cases here whenever extraHint's matcher widens, to keep the
|
||||
// promise that the hint is suggested iff downgrade can actually help.
|
||||
func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
wantHint bool
|
||||
}{
|
||||
{"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true},
|
||||
{"not initialized (missing master key)", errNotInitialized, true},
|
||||
{"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false},
|
||||
{"unrelated generic error", errors.New("something else entirely"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := wrapError("Get", tc.err)
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) || ee.Detail == nil {
|
||||
t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err)
|
||||
}
|
||||
got := strings.Contains(ee.Detail.Hint, "keychain-downgrade")
|
||||
if got != tc.wantHint {
|
||||
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
|
||||
// master key path once the fallback master key already exists.
|
||||
func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {
|
||||
|
||||
10
internal/keychain/keychain_hint_other.go
Normal file
10
internal/keychain/keychain_hint_other.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package keychain
|
||||
|
||||
// extraHint is a no-op on non-darwin platforms. The keychain-downgrade
|
||||
// command is macOS-only, so there is no extra suggestion to surface.
|
||||
func extraHint(err error) string { return "" }
|
||||
@@ -22,6 +22,64 @@ var registryFS embed.FS
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
|
||||
874
internal/schema/assembler.go
Normal file
874
internal/schema/assembler.go
Normal file
@@ -0,0 +1,874 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// MethodKeyOrder records the natural meta_data.json key order for one method's
|
||||
// parameters / requestBody / responseBody. Nested object key orders are stored
|
||||
// under NestedKeys, keyed by dotted path from the method root
|
||||
// (e.g. "responseBody.items.properties").
|
||||
type MethodKeyOrder struct {
|
||||
Parameters []string
|
||||
RequestBody []string
|
||||
ResponseBody []string
|
||||
NestedKeys map[string][]string
|
||||
}
|
||||
|
||||
var (
|
||||
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
|
||||
keyOrderInitOnce sync.Once
|
||||
)
|
||||
|
||||
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
|
||||
// or nil if the method is not in the embedded data (e.g. remote-cached).
|
||||
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
|
||||
keyOrderInitOnce.Do(buildKeyOrderIndex)
|
||||
if keyOrderIndex == nil {
|
||||
return nil
|
||||
}
|
||||
dotted := dottedPath(service, resourcePath, method)
|
||||
return keyOrderIndex[dotted]
|
||||
}
|
||||
|
||||
func dottedPath(service string, resourcePath []string, method string) string {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(service)
|
||||
for _, r := range resourcePath {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(r)
|
||||
}
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(method)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
|
||||
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
|
||||
// and recording each map's key insertion order via json.Decoder.Token().
|
||||
func buildKeyOrderIndex() {
|
||||
raw := registry.EmbeddedMetaJSON()
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
keyOrderIndex = make(map[string]*MethodKeyOrder)
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
// Top-level: { "services": [...], "version": "..." }
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
if key != "services" {
|
||||
skipValue(dec)
|
||||
continue
|
||||
}
|
||||
if !expectDelim(dec, '[') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
parseService(dec)
|
||||
}
|
||||
// closing ]
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
}
|
||||
|
||||
// parseService consumes one service object inside services[].
|
||||
// meta_data.json may emit "resources" before "name", so we first capture both
|
||||
// raw fields, then walk resources with the resolved service name.
|
||||
func parseService(dec *json.Decoder) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
var serviceName string
|
||||
var resourcesRaw json.RawMessage
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "name":
|
||||
tok, _ := dec.Token()
|
||||
if s, ok := tok.(string); ok {
|
||||
serviceName = s
|
||||
}
|
||||
case "resources":
|
||||
if err := dec.Decode(&resourcesRaw); err != nil {
|
||||
skipValue(dec)
|
||||
}
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token() // closing }
|
||||
if serviceName != "" && len(resourcesRaw) > 0 {
|
||||
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
|
||||
parseResources(subDec, serviceName, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// parseResources walks a resources map (resName -> resource object).
|
||||
// resourcePath is the accumulated path of parent resources (for nested resources).
|
||||
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
resName, _ := readKey(dec)
|
||||
parseResourceObj(dec, service, append(resourcePath, resName))
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
|
||||
// recurse into nested resources via "resources" key if present.
|
||||
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "methods":
|
||||
parseMethods(dec, service, resourcePath)
|
||||
case "resources":
|
||||
parseResources(dec, service, resourcePath)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethods consumes the methods map (methodName -> method object).
|
||||
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
methodName, _ := readKey(dec)
|
||||
mko := parseMethod(dec)
|
||||
dotted := dottedPath(service, resourcePath, methodName)
|
||||
keyOrderIndex[dotted] = mko
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethod consumes one method object and records key orders.
|
||||
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
|
||||
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
|
||||
if !expectDelim(dec, '{') {
|
||||
return mko
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "parameters":
|
||||
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
|
||||
case "requestBody":
|
||||
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
|
||||
case "responseBody":
|
||||
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
return mko
|
||||
}
|
||||
|
||||
// recordObjectKeysRecursive consumes an object and records the top-level key
|
||||
// order. It also recurses into each child's "properties" submap, recording
|
||||
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
|
||||
// in order.
|
||||
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
|
||||
if !expectDelim(dec, '{') {
|
||||
return nil
|
||||
}
|
||||
var order []string
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
order = append(order, key)
|
||||
// Each child value is itself an object; we want its nested "properties" order if present.
|
||||
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
if prefix != "" && len(order) > 0 {
|
||||
nestedKeys[prefix] = order
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
|
||||
// if it contains "properties": {...}, recursively records that submap's order.
|
||||
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok || delim != '{' {
|
||||
// Not an object — skip the rest of the value
|
||||
skipValueAfterToken(dec, tok)
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
fieldKey, _ := readKey(dec)
|
||||
if fieldKey == "properties" {
|
||||
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
|
||||
} else {
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// --- json.Decoder helpers ---
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) bool {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
return ok && delim == want
|
||||
}
|
||||
|
||||
func readKey(dec *json.Decoder) (string, error) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, _ := tok.(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// skipValue consumes the next complete value (scalar, object, or array).
|
||||
func skipValue(dec *json.Decoder) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
skipValueAfterToken(dec, tok)
|
||||
}
|
||||
|
||||
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// We started inside a container of type `delim` ({ or [) and must eat
|
||||
// tokens until that container closes, tracking nested containers of any
|
||||
// kind. depth counts how many open containers we are currently inside.
|
||||
_ = delim
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if d, ok := t.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coerceLiteral converts a meta_data literal (default / enum / example) to
|
||||
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
||||
// meta_data stores every literal as a string, so without coercion an
|
||||
// `integer` field would emit string literals and fail any standard validator.
|
||||
// Already-typed values pass through unchanged. Returns (value, true) on
|
||||
// success, or (nil, false) when the literal cannot be coerced (caller should
|
||||
// drop it).
|
||||
func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) {
|
||||
s, isStr := raw.(string)
|
||||
if !isStr {
|
||||
// Already typed (e.g. meta_data emitted a JSON number/bool directly).
|
||||
return raw, true
|
||||
}
|
||||
switch fieldType {
|
||||
case "integer":
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
case "number":
|
||||
if v, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
case "boolean":
|
||||
switch s {
|
||||
case "true":
|
||||
return true, true
|
||||
case "false":
|
||||
return false, true
|
||||
}
|
||||
return nil, false
|
||||
default: // "string", "" (nested objects), or unknown
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
// sortEnum sorts an enum slice in-place using a comparator appropriate for
|
||||
// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather
|
||||
// than the lexicographic [1, 10, 2].
|
||||
func sortEnum(fieldType string, vals []interface{}) {
|
||||
sort.SliceStable(vals, func(i, j int) bool {
|
||||
switch fieldType {
|
||||
case "integer":
|
||||
ai, _ := vals[i].(int64)
|
||||
bi, _ := vals[j].(int64)
|
||||
return ai < bi
|
||||
case "number":
|
||||
af, _ := vals[i].(float64)
|
||||
bf, _ := vals[j].(float64)
|
||||
return af < bf
|
||||
case "boolean":
|
||||
ab, _ := vals[i].(bool)
|
||||
bb, _ := vals[j].(bool)
|
||||
return !ab && bb // false < true
|
||||
default:
|
||||
as, _ := vals[i].(string)
|
||||
bs, _ := vals[j].(string)
|
||||
return as < bs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// convertProperty recursively converts one meta_data field map into a Property.
|
||||
// nestedPath is the dotted lookup key into the current method's NestedKeys map
|
||||
// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested
|
||||
// lookup needed.
|
||||
func convertProperty(field map[string]interface{}, nestedPath string) Property {
|
||||
var p Property
|
||||
|
||||
rawType, _ := field["type"].(string)
|
||||
switch rawType {
|
||||
case "file":
|
||||
p.Type = "string"
|
||||
p.Format = "binary"
|
||||
case "list":
|
||||
// meta_data uses non-standard "list" on a couple of fields;
|
||||
// translate to JSON Schema "array" so validators accept it.
|
||||
p.Type = "array"
|
||||
default:
|
||||
p.Type = rawType
|
||||
}
|
||||
|
||||
if s, ok := field["description"].(string); ok {
|
||||
p.Description = s
|
||||
}
|
||||
if v, ok := field["default"]; ok {
|
||||
// Coerce default literal to match the declared JSON Schema type so
|
||||
// validators do not reject e.g. {type:"integer", default:"500"}.
|
||||
// When coercion fails (e.g. default:"" on an integer field, which
|
||||
// meta_data uses to mean "no default"), omit the field entirely
|
||||
// instead of emitting a type-mismatched default — the result is a
|
||||
// missing `default` key rather than a contract violation.
|
||||
if coerced, ok := coerceLiteral(p.Type, v); ok {
|
||||
p.Default = coerced
|
||||
}
|
||||
}
|
||||
if v, ok := field["example"]; ok {
|
||||
// meta_data stores examples as strings even when the field is integer/
|
||||
// boolean/number; coerce to the declared type so downstream validators
|
||||
// accept the envelope. Drop on coerce failure (same policy as default).
|
||||
if coerced, ok := coerceLiteral(p.Type, v); ok {
|
||||
p.Example = coerced
|
||||
}
|
||||
}
|
||||
|
||||
// min / max are stored as strings in meta_data; parse on best-effort.
|
||||
if minStr, ok := field["min"].(string); ok && minStr != "" {
|
||||
if v, err := strconv.ParseFloat(minStr, 64); err == nil {
|
||||
p.Minimum = &v
|
||||
}
|
||||
}
|
||||
if maxStr, ok := field["max"].(string); ok && maxStr != "" {
|
||||
if v, err := strconv.ParseFloat(maxStr, 64); err == nil {
|
||||
p.Maximum = &v
|
||||
}
|
||||
}
|
||||
|
||||
// enum: prefer existing "enum" array; else extract from options[].value.
|
||||
// Values are typed per p.Type so integer fields get integer enums, etc.
|
||||
// (JSON Schema 2020-12 requires enum value types to match the declared
|
||||
// type — meta_data stores everything as strings.)
|
||||
if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 {
|
||||
for _, e := range enumRaw {
|
||||
if v, ok := coerceLiteral(p.Type, e); ok {
|
||||
p.Enum = append(p.Enum, v)
|
||||
}
|
||||
}
|
||||
// Numeric/boolean enums get sorted (no inherent meaning in meta_data
|
||||
// order); string enums keep meta_data order, which sometimes carries
|
||||
// semantic priority (e.g. image_type ["message","avatar"]).
|
||||
if p.Type != "string" && p.Type != "" {
|
||||
sortEnum(p.Type, p.Enum)
|
||||
}
|
||||
} else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
for _, o := range optsRaw {
|
||||
om, ok := o.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw, ok := om["value"].(string)
|
||||
if !ok || seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
if v, ok := coerceLiteral(p.Type, raw); ok {
|
||||
p.Enum = append(p.Enum, v)
|
||||
}
|
||||
}
|
||||
// Same policy as the `enum` branch: numeric/boolean enums get sorted
|
||||
// (no semantic meaning in source order); string enums keep meta_data
|
||||
// order, which may carry semantic priority.
|
||||
if p.Type != "string" && p.Type != "" {
|
||||
sortEnum(p.Type, p.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
// nested properties: recurse
|
||||
if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 {
|
||||
nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath)
|
||||
if p.Type == "array" {
|
||||
// meta_data quirk: array element schema is wrapped in "properties".
|
||||
// Unfold into Items: { type: "object", properties: <nested> }
|
||||
p.Items = &Property{
|
||||
Type: "object",
|
||||
Properties: nested,
|
||||
Required: nestedRequired,
|
||||
}
|
||||
// Property.Properties stays nil for arrays
|
||||
} else {
|
||||
if p.Type == "" {
|
||||
p.Type = "object" // infer
|
||||
}
|
||||
p.Properties = nested
|
||||
p.Required = nestedRequired
|
||||
}
|
||||
}
|
||||
|
||||
// array items fallback: emit `items: {}` (any schema) for every array that
|
||||
// meta_data does not describe an element shape for — whether it arrived as
|
||||
// "list" or natively as "array". Without this, typeless arrays (e.g. arrays
|
||||
// of bare ID strings) violate the L1 lint rule and are not JSON Schema valid
|
||||
// for consumers that require `items`.
|
||||
if p.Type == "array" && p.Items == nil {
|
||||
p.Items = &Property{}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// buildOrderedProps converts a map[string]interface{} of field specs into an
|
||||
// OrderedProps plus the alphabetized list of child keys marked `required:true`
|
||||
// in meta_data. Callers attach that list to the enclosing object's `required`,
|
||||
// so nested objects faithfully report their call contract (top-level required
|
||||
// is handled separately by buildInputSchema).
|
||||
func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) {
|
||||
op := &OrderedProps{Map: make(map[string]Property, len(raw))}
|
||||
|
||||
var required []string
|
||||
keys := orderedKeys(raw, nestedPath)
|
||||
for _, k := range keys {
|
||||
fieldRaw, _ := raw[k].(map[string]interface{})
|
||||
op.Order = append(op.Order, k)
|
||||
op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties")
|
||||
if req, _ := fieldRaw["required"].(bool); req {
|
||||
required = append(required, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(required)
|
||||
return op, required
|
||||
}
|
||||
|
||||
// currentMethodOrder is the per-method key-order context used by orderedKeys.
|
||||
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
|
||||
var currentMethodOrder *MethodKeyOrder
|
||||
|
||||
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
|
||||
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
|
||||
// or carries no populated subfields.
|
||||
//
|
||||
// Affordance is authored in larksuite-cli-registry's registry-config.yaml under
|
||||
// overrides.<resource>.<method>.affordance and flows through gen-registry.py's
|
||||
// deep_merge into the embedded meta_data.json.
|
||||
func parseAffordance(raw interface{}) *Affordance {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var a Affordance
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
// convertAccessTokens translates from_meta accessTokens (uses "tenant") into
|
||||
// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically.
|
||||
// Unknown tokens are dropped. Returns an empty slice for nil/empty input.
|
||||
func convertAccessTokens(raw []interface{}) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range raw {
|
||||
s, ok := t.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch s {
|
||||
case "tenant":
|
||||
seen["bot"] = true
|
||||
case "user":
|
||||
seen["user"] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMeta produces the _meta extension namespace.
|
||||
func buildMeta(method map[string]interface{}) *Meta {
|
||||
m := &Meta{
|
||||
EnvelopeVersion: "1.0",
|
||||
RequiredScopes: []string{}, // never nil for stable JSON
|
||||
}
|
||||
|
||||
if scopesRaw, ok := method["scopes"].([]interface{}); ok {
|
||||
for _, s := range scopesRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
m.Scopes = append(m.Scopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rsRaw, ok := method["requiredScopes"].([]interface{}); ok {
|
||||
for _, s := range rsRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
m.RequiredScopes = append(m.RequiredScopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
atRaw, _ := method["accessTokens"].([]interface{})
|
||||
m.AccessTokens = convertAccessTokens(atRaw)
|
||||
|
||||
m.Danger, _ = method["danger"].(bool)
|
||||
|
||||
if risk, _ := method["risk"].(string); risk != "" {
|
||||
m.Risk = risk
|
||||
} else {
|
||||
m.Risk = cmdutil.RiskRead
|
||||
}
|
||||
|
||||
if docURL, _ := method["docUrl"].(string); docURL != "" {
|
||||
m.DocURL = docURL
|
||||
}
|
||||
|
||||
m.Affordance = parseAffordance(method["affordance"])
|
||||
return m
|
||||
}
|
||||
|
||||
// buildInputSchema produces the inputSchema for one API method.
|
||||
//
|
||||
// Top-level shape:
|
||||
//
|
||||
// { type: object,
|
||||
// required: [<"params" if any param required>, <"data" if any body required>],
|
||||
// properties: {
|
||||
// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters
|
||||
// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody
|
||||
// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write"
|
||||
// } }
|
||||
//
|
||||
// The params / data wrapping mirrors the CLI's actual flag layout:
|
||||
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
||||
// can pluck inputSchema.properties.params and pass it verbatim to --params.
|
||||
//
|
||||
// Caller must set currentMethodOrder for property-order preservation.
|
||||
func buildInputSchema(method map[string]interface{}) *InputSchema {
|
||||
is := &InputSchema{
|
||||
Type: "object",
|
||||
Required: []string{}, // never nil — stable envelope shape
|
||||
Properties: &OrderedProps{Map: make(map[string]Property)},
|
||||
}
|
||||
|
||||
// Build the "params" sub-object from method.parameters (path + query).
|
||||
paramsRaw, _ := method["parameters"].(map[string]interface{})
|
||||
paramsProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
var paramsRequired []string
|
||||
for _, k := range orderedKeys(paramsRaw, "parameters") {
|
||||
field, _ := paramsRaw[k].(map[string]interface{})
|
||||
prop := convertProperty(field, "parameters."+k+".properties")
|
||||
paramsProps.Order = append(paramsProps.Order, k)
|
||||
paramsProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
paramsRequired = append(paramsRequired, k)
|
||||
}
|
||||
}
|
||||
if len(paramsProps.Order) > 0 {
|
||||
sort.Strings(paramsRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "params")
|
||||
is.Properties.Map["params"] = Property{
|
||||
Type: "object",
|
||||
Required: paramsRequired,
|
||||
Properties: paramsProps,
|
||||
}
|
||||
if len(paramsRequired) > 0 {
|
||||
is.Required = append(is.Required, "params")
|
||||
}
|
||||
}
|
||||
|
||||
// Split method.requestBody into two buckets:
|
||||
// - data: non-file body fields → corresponds to CLI --data JSON
|
||||
// - file: type:file body fields → corresponds to CLI --file <key>=<path>
|
||||
// File fields are kept *out* of `data` so the schema mirrors the actual
|
||||
// CLI flag dispatch: --file owns one wire format (multipart upload),
|
||||
// --data owns the rest (JSON body).
|
||||
bodyRaw, _ := method["requestBody"].(map[string]interface{})
|
||||
dataProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
fileProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
var dataRequired []string
|
||||
var fileRequired []string
|
||||
for _, k := range orderedKeys(bodyRaw, "requestBody") {
|
||||
field, _ := bodyRaw[k].(map[string]interface{})
|
||||
prop := convertProperty(field, "requestBody."+k+".properties")
|
||||
isFile := false
|
||||
if t, _ := field["type"].(string); t == "file" {
|
||||
isFile = true
|
||||
}
|
||||
if isFile {
|
||||
fileProps.Order = append(fileProps.Order, k)
|
||||
fileProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
fileRequired = append(fileRequired, k)
|
||||
}
|
||||
} else {
|
||||
dataProps.Order = append(dataProps.Order, k)
|
||||
dataProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
dataRequired = append(dataRequired, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dataProps.Order) > 0 {
|
||||
sort.Strings(dataRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "data")
|
||||
is.Properties.Map["data"] = Property{
|
||||
Type: "object",
|
||||
Required: dataRequired,
|
||||
Properties: dataProps,
|
||||
}
|
||||
if len(dataRequired) > 0 {
|
||||
is.Required = append(is.Required, "data")
|
||||
}
|
||||
}
|
||||
if len(fileProps.Order) > 0 {
|
||||
sort.Strings(fileRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "file")
|
||||
is.Properties.Map["file"] = Property{
|
||||
Type: "object",
|
||||
Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.",
|
||||
Required: fileRequired,
|
||||
Properties: fileProps,
|
||||
}
|
||||
if len(fileRequired) > 0 {
|
||||
is.Required = append(is.Required, "file")
|
||||
}
|
||||
}
|
||||
|
||||
// high-risk-write injects a top-level `yes` confirmation flag — sibling
|
||||
// of params/data. It is a CLI gate (consumed by lark-cli, not sent to
|
||||
// the backend), not an API field.
|
||||
if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite {
|
||||
is.Properties.Order = append(is.Properties.Order, "yes")
|
||||
falseVal := false
|
||||
is.Properties.Map["yes"] = Property{
|
||||
Type: "boolean",
|
||||
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.",
|
||||
}
|
||||
// yes is intentionally NOT added to top-level Required; the gate is
|
||||
// enforced semantically (yes==true) by the CLI, not structurally.
|
||||
}
|
||||
|
||||
sort.Strings(is.Required) // alphabetical
|
||||
return is
|
||||
}
|
||||
|
||||
// buildOutputSchema produces the outputSchema for one API method.
|
||||
func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
||||
os := &OutputSchema{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{Map: make(map[string]Property)},
|
||||
}
|
||||
respRaw, _ := method["responseBody"].(map[string]interface{})
|
||||
for _, k := range orderedKeys(respRaw, "responseBody") {
|
||||
field, _ := respRaw[k].(map[string]interface{})
|
||||
os.Properties.Order = append(os.Properties.Order, k)
|
||||
os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties")
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
// assembleMu serializes AssembleEnvelope calls so that the package-level
|
||||
// currentMethodOrder pointer is safe for concurrent callers.
|
||||
var assembleMu sync.Mutex
|
||||
|
||||
// AssembleEnvelope is the main entry point: takes a service / resource path /
|
||||
// method name plus its meta_data spec, and produces a fully assembled MCP
|
||||
// envelope. Output is fully determined by inputs (same arguments → same
|
||||
// envelope), but assembly briefly publishes the per-method key-order context
|
||||
// through the package-level currentMethodOrder so orderedKeys can reach it
|
||||
// without threading it through every helper. assembleMu serializes that
|
||||
// publish, which is why concurrent callers are still safe — they queue
|
||||
// rather than run in parallel.
|
||||
//
|
||||
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
|
||||
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
|
||||
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
|
||||
assembleMu.Lock()
|
||||
defer assembleMu.Unlock()
|
||||
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
name := serviceName
|
||||
for _, r := range resourcePath {
|
||||
name += " " + r
|
||||
}
|
||||
name += " " + methodName
|
||||
|
||||
desc, _ := method["description"].(string)
|
||||
|
||||
return Envelope{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
InputSchema: buildInputSchema(method),
|
||||
OutputSchema: buildOutputSchema(method),
|
||||
Meta: buildMeta(method),
|
||||
}
|
||||
}
|
||||
|
||||
// MethodFilter is an optional predicate used by AssembleService and
|
||||
// AssembleAll to filter methods (e.g. by access token for strict mode).
|
||||
// Pass nil to include all methods.
|
||||
type MethodFilter func(method map[string]interface{}) bool
|
||||
|
||||
// AssembleService assembles all methods under one service into a sorted
|
||||
// envelope slice (sorted by Envelope.Name ascending).
|
||||
func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
var out []Envelope
|
||||
walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) {
|
||||
if filter != nil && !filter(method) {
|
||||
return
|
||||
}
|
||||
out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// AssembleAll assembles every embedded service into one big sorted slice.
|
||||
// Uses embedded data only (bypasses remote overlay) so envelope output is
|
||||
// deterministic across machines (CI vs dev vs different user brands).
|
||||
func AssembleAll(filter MethodFilter) []Envelope {
|
||||
var out []Envelope
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
out = append(out, AssembleService(svc, spec, filter)...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// walkMethods recursively walks resources -> methods, calling visit for each
|
||||
// terminal method. It supports nested resources via the optional "resources"
|
||||
// key inside a resource value (matches meta_data.json structure).
|
||||
func walkMethods(resources map[string]interface{}, parentPath []string,
|
||||
visit func(resourcePath []string, methodName string, method map[string]interface{})) {
|
||||
for resName, resRaw := range resources {
|
||||
resMap, ok := resRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
curPath := append(append([]string(nil), parentPath...), resName)
|
||||
if methods, ok := resMap["methods"].(map[string]interface{}); ok {
|
||||
for mName, mRaw := range methods {
|
||||
if m, ok := mRaw.(map[string]interface{}); ok {
|
||||
visit(curPath, mName, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if nested, ok := resMap["resources"].(map[string]interface{}); ok {
|
||||
walkMethods(nested, curPath, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys returns the keys of raw in their meta_data natural order if
|
||||
// the current per-method key-order context has them recorded; otherwise
|
||||
// alphabetical fallback.
|
||||
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
|
||||
if currentMethodOrder != nil && nestedPath != "" {
|
||||
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
|
||||
// Filter to keys that actually exist in raw (defensive)
|
||||
out := make([]string, 0, len(order))
|
||||
seen := make(map[string]bool)
|
||||
for _, k := range order {
|
||||
if _, ok := raw[k]; ok {
|
||||
out = append(out, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
// Append any keys present in raw but missing from order (defensive),
|
||||
// alphabetically for determinism.
|
||||
var extra []string
|
||||
for k := range raw {
|
||||
if !seen[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
out = append(out, extra...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
// Fallback: alphabetical
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
781
internal/schema/assembler_test.go
Normal file
781
internal/schema/assembler_test.go
Normal file
@@ -0,0 +1,781 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||
// the suite gives the same answer on every machine. Without this, a stale
|
||||
// local remote_meta.json could surface methods that aren't in the embedded
|
||||
// snapshot (or alter their data) depending on the contributor's environment.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup is done explicitly
|
||||
// after m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "schema-test-cfg-*")
|
||||
if err != nil {
|
||||
// Surface the failure rather than silently running against the host
|
||||
// cache — that defeats the whole purpose of this isolation.
|
||||
println("schema test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||||
// We only assert key-set membership, not absolute order — the upstream
|
||||
// meta_data API does not guarantee a stable JSON key sequence across
|
||||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||||
}
|
||||
wantParams := map[string]bool{
|
||||
"message_id": true, "reaction_type": true, "page_token": true,
|
||||
"page_size": true, "user_id_type": true,
|
||||
}
|
||||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||||
}
|
||||
for _, k := range order.Parameters {
|
||||
if !wantParams[k] {
|
||||
t.Errorf("unexpected parameter key %q", k)
|
||||
}
|
||||
}
|
||||
// im.reactions.list 是 GET,没有 requestBody
|
||||
if len(order.RequestBody) != 0 {
|
||||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.images.create, got nil")
|
||||
}
|
||||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||||
}
|
||||
for _, k := range order.RequestBody {
|
||||
if !wantBody[k] {
|
||||
t.Errorf("unexpected requestBody key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||||
if order != nil {
|
||||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
wantType string
|
||||
}{
|
||||
{"string", map[string]interface{}{"type": "string"}, "string"},
|
||||
{"integer", map[string]interface{}{"type": "integer"}, "integer"},
|
||||
{"boolean", map[string]interface{}{"type": "boolean"}, "boolean"},
|
||||
{"number", map[string]interface{}{"type": "number"}, "number"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertProperty(tt.input, "")
|
||||
if got.Type != tt.wantType {
|
||||
t.Errorf("Type = %q, want %q", got.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_FileBinary(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "file", "description": "upload"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "string" {
|
||||
t.Errorf("Type = %q, want \"string\"", got.Type)
|
||||
}
|
||||
if got.Format != "binary" {
|
||||
t.Errorf("Format = %q, want \"binary\"", got.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_OptionsToEnum(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "banana"},
|
||||
map[string]interface{}{"value": "apple"},
|
||||
map[string]interface{}{"value": "banana"}, // duplicate
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
// string enums preserve source order (deduped), matching the `enum`
|
||||
// branch. Numeric/boolean enums would still be sorted by value.
|
||||
want := []interface{}{"banana", "apple"}
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_EnumPassThrough(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []interface{}{"x", "y"},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
want := []interface{}{"x", "y"} // pass through, no sort
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_EnumIntegerCoerce(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "integer",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "10"},
|
||||
map[string]interface{}{"value": "1"},
|
||||
map[string]interface{}{"value": "2"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ListTypeFallback(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "list",
|
||||
"description": "ids",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "array" {
|
||||
t.Errorf("Type = %q, want %q", got.Type, "array")
|
||||
}
|
||||
if got.Items == nil {
|
||||
t.Fatalf("Items = nil, want non-nil (any-schema fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_MinMaxParsing(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Minimum == nil || *got.Minimum != 10.0 {
|
||||
t.Errorf("Minimum = %v, want 10", got.Minimum)
|
||||
}
|
||||
if got.Maximum == nil || *got.Maximum != 50.0 {
|
||||
t.Errorf("Maximum = %v, want 50", got.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_MinMaxInvalid(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "integer", "min": "not_a_number"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Minimum != nil {
|
||||
t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ArrayWithProperties(t *testing.T) {
|
||||
// meta_data quirk: array element schema is in "properties" not "items"
|
||||
input := map[string]interface{}{
|
||||
"type": "array",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "array" {
|
||||
t.Fatalf("Type = %q, want \"array\"", got.Type)
|
||||
}
|
||||
if got.Items == nil {
|
||||
t.Fatal("Items is nil, want non-nil")
|
||||
}
|
||||
if got.Items.Type != "object" {
|
||||
t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type)
|
||||
}
|
||||
if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 {
|
||||
t.Errorf("Items.Properties did not contain both id and name")
|
||||
}
|
||||
if got.Properties != nil {
|
||||
t.Error("array Property must not have top-level Properties after unfold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ObjectWithProperties(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"x": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", got.Type)
|
||||
}
|
||||
if got.Properties == nil || got.Properties.Map["x"].Type != "string" {
|
||||
t.Errorf("nested Properties not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_InferObjectFromProperties(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"y": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\" (inferred)", got.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"ref": "operator",
|
||||
"annotations": []interface{}{"readOnly"},
|
||||
"enumName": "FooEnum",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
// 这些字段直接被丢弃;Property 结构里也没存这些字段,断言只有 type 设置即可
|
||||
if got.Type != "string" {
|
||||
t.Errorf("Type = %q", got.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "hello\nworld",
|
||||
"default": "",
|
||||
"example": "ex",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Description != "hello\nworld" {
|
||||
t.Errorf("Description not preserved verbatim")
|
||||
}
|
||||
if got.Default != "" {
|
||||
t.Errorf("Default = %v, want empty string (preserved)", got.Default)
|
||||
}
|
||||
if got.Example != "ex" {
|
||||
t.Errorf("Example = %v, want \"ex\"", got.Example)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
if is.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", is.Type)
|
||||
}
|
||||
// top-level required: ["params"] because message_id is a required path param
|
||||
if !reflect.DeepEqual(is.Required, []string{"params"}) {
|
||||
t.Errorf("Required = %v, want [params]", is.Required)
|
||||
}
|
||||
// top-level properties only contains "params" (no body fields, no high-risk-write)
|
||||
if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) {
|
||||
t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order)
|
||||
}
|
||||
// params sub-object: required + property order
|
||||
params := is.Properties.Map["params"]
|
||||
if params.Type != "object" {
|
||||
t.Errorf("params.Type = %q, want \"object\"", params.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||||
params.Properties.Order, mko.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// top-level required: ["data", "file"] — image_type body required + image file required
|
||||
if !reflect.DeepEqual(is.Required, []string{"data", "file"}) {
|
||||
t.Errorf("Required = %v, want [data, file]", is.Required)
|
||||
}
|
||||
// top-level properties: data (for non-file body) + file (for binary upload)
|
||||
if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) {
|
||||
t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order)
|
||||
}
|
||||
// data sub-object carries only non-file body fields (image_type)
|
||||
data := is.Properties.Map["data"]
|
||||
if !reflect.DeepEqual(data.Required, []string{"image_type"}) {
|
||||
t.Errorf("data.Required = %v, want [image_type]", data.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) {
|
||||
t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order)
|
||||
}
|
||||
if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) {
|
||||
t.Errorf("image_type unexpected: %+v", it)
|
||||
}
|
||||
if _, isFile := data.Properties.Map["image"]; isFile {
|
||||
t.Errorf("image (file field) should NOT appear in data sub-object")
|
||||
}
|
||||
|
||||
// file sub-object carries the binary upload field
|
||||
file := is.Properties.Map["file"]
|
||||
if file.Type != "object" {
|
||||
t.Errorf("file.Type = %q, want \"object\"", file.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(file.Required, []string{"image"}) {
|
||||
t.Errorf("file.Required = %v, want [image]", file.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) {
|
||||
t.Errorf("file.properties order = %v, want [image]", file.Properties.Order)
|
||||
}
|
||||
img := file.Properties.Map["image"]
|
||||
if img.Type != "string" {
|
||||
t.Errorf("image.Type = %q, want \"string\"", img.Type)
|
||||
}
|
||||
if img.Format != "binary" {
|
||||
t.Errorf("image.Format = %q, want \"binary\"", img.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
// Synthesized method to avoid registry-overlay variance (remote cache may
|
||||
// strip `risk` field); buildInputSchema only cares about the method map.
|
||||
method := map[string]interface{}{
|
||||
"risk": "high-risk-write",
|
||||
"parameters": map[string]interface{}{
|
||||
"message_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
yes, ok := is.Properties.Map["yes"]
|
||||
if !ok {
|
||||
t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found")
|
||||
}
|
||||
if yes.Type != "boolean" {
|
||||
t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type)
|
||||
}
|
||||
if v, _ := yes.Default.(bool); v != false {
|
||||
t.Errorf("yes.Default = %v, want false", yes.Default)
|
||||
}
|
||||
// yes must NOT be in top-level required
|
||||
for _, r := range is.Required {
|
||||
if r == "yes" {
|
||||
t.Errorf("`yes` should not appear in top-level required")
|
||||
}
|
||||
}
|
||||
// yes is appended to properties.Order
|
||||
last := is.Properties.Order[len(is.Properties.Order)-1]
|
||||
if last != "yes" {
|
||||
t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
if _, ok := is.Properties.Map["yes"]; ok {
|
||||
t.Errorf("`yes` must not be injected for risk=read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
os := buildOutputSchema(method)
|
||||
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
}
|
||||
// Top-level response: has_more, page_token, items
|
||||
if _, ok := os.Properties.Map["items"]; !ok {
|
||||
t.Fatal("items not found in outputSchema")
|
||||
}
|
||||
items := os.Properties.Map["items"]
|
||||
if items.Type != "array" {
|
||||
t.Errorf("items.Type = %q, want \"array\"", items.Type)
|
||||
}
|
||||
if items.Items == nil {
|
||||
t.Fatal("items.Items is nil (array unfold failed)")
|
||||
}
|
||||
if items.Items.Type != "object" {
|
||||
t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAccessTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []interface{}
|
||||
want []string
|
||||
}{
|
||||
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
|
||||
{"user only", []interface{}{"user"}, []string{"user"}},
|
||||
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
|
||||
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
|
||||
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
|
||||
{"empty", []interface{}{}, []string{}},
|
||||
{"nil", nil, []string{}},
|
||||
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertAccessTokens(tt.input)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_FullFields(t *testing.T) {
|
||||
// Synthesized method to avoid runtime variance from remote-cache overlay
|
||||
// (which strips `risk` from merged services). All other field semantics
|
||||
// match the real im.images.create entry in meta_data.json.
|
||||
method := map[string]interface{}{
|
||||
"risk": "write",
|
||||
"danger": true,
|
||||
"scopes": []interface{}{
|
||||
"im:resource:upload",
|
||||
"im:resource",
|
||||
},
|
||||
"accessTokens": []interface{}{"tenant"},
|
||||
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
|
||||
}
|
||||
m := buildMeta(method)
|
||||
|
||||
if m.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
|
||||
}
|
||||
if m.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want \"write\"", m.Risk)
|
||||
}
|
||||
if !m.Danger {
|
||||
t.Errorf("Danger = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) {
|
||||
t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens)
|
||||
}
|
||||
if m.DocURL == "" {
|
||||
t.Errorf("DocURL should be present for im.images.create")
|
||||
}
|
||||
if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) {
|
||||
t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes)
|
||||
}
|
||||
if m.RequiredScopes == nil {
|
||||
t.Errorf("RequiredScopes should be empty slice, not nil")
|
||||
}
|
||||
if len(m.RequiredScopes) != 0 {
|
||||
t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes)
|
||||
}
|
||||
if m.Affordance != nil {
|
||||
t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
// no risk field
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.Risk != "read" {
|
||||
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get")
|
||||
m := buildMeta(method)
|
||||
if len(m.RequiredScopes) == 0 {
|
||||
t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_NilOrEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw interface{}
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty object", map[string]interface{}{}},
|
||||
{"all-five-empty-arrays", map[string]interface{}{
|
||||
"use_when": []interface{}{},
|
||||
"do_not_use_when": []interface{}{},
|
||||
"prerequisites": []interface{}{},
|
||||
"examples": []interface{}{},
|
||||
"related": []interface{}{},
|
||||
}},
|
||||
{"malformed (string)", "not an object"},
|
||||
{"malformed (number)", 42},
|
||||
{"malformed (nested type mismatch)", map[string]interface{}{
|
||||
"examples": "should be a list, not a string",
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseAffordance(c.raw); got != nil {
|
||||
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_FullPopulated(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
|
||||
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
|
||||
"prerequisites": []interface{}{"user 身份登录"},
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"title": "获取主日历", "input": map[string]interface{}{}},
|
||||
},
|
||||
"related": []interface{}{"calendars.list"},
|
||||
}
|
||||
a := parseAffordance(raw)
|
||||
if a == nil {
|
||||
t.Fatal("parseAffordance returned nil, want populated")
|
||||
}
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Title != "获取主日历" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
"risk": "read",
|
||||
"affordance": map[string]interface{}{
|
||||
"use_when": []interface{}{"trigger"},
|
||||
},
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("Affordance should be populated from method[\"affordance\"]")
|
||||
}
|
||||
if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" {
|
||||
t.Errorf("UseWhen = %v", m.Affordance.UseWhen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
"risk": "read",
|
||||
// no docUrl
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.DocURL != "" {
|
||||
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
|
||||
}
|
||||
// Verify JSON serialization omits doc_url
|
||||
b, _ := json.Marshal(m)
|
||||
if strings.Contains(string(b), "doc_url") {
|
||||
t.Errorf("doc_url should be omitted from JSON, got: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||
method := map[string]interface{}{}
|
||||
currentMethodOrder = nil
|
||||
os := buildOutputSchema(method)
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
}
|
||||
if os.Properties == nil {
|
||||
t.Fatal("Properties is nil, want empty OrderedProps")
|
||||
}
|
||||
if len(os.Properties.Order) != 0 {
|
||||
t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
|
||||
if env.Name != "im reactions list" {
|
||||
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
|
||||
}
|
||||
if env.Description == "" {
|
||||
t.Errorf("Description should not be empty for im.reactions.list")
|
||||
}
|
||||
if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil {
|
||||
t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil")
|
||||
}
|
||||
if env.Meta.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
|
||||
// im.chat.members.create — resource path is one element "chat.members" with
|
||||
// an internal dot. Substituted from plan's `bots` because remote-cache
|
||||
// overlay strips `bots` from the loaded method map on this environment;
|
||||
// the assertion is about name joining, not method specifics.
|
||||
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
|
||||
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
|
||||
// chat.members resourcePath stays as one element in the slice with a dot;
|
||||
// name should split it to "im chat.members create" — we keep the dot as-is
|
||||
// inside the resource segment to round-trip with completion logic.
|
||||
if env.Name != "im chat.members create" {
|
||||
t.Errorf("Name = %q, want \"im chat.members create\"", env.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
|
||||
// Assemble twice; JSON output must be byte-identical (determinism).
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
ja, _ := json.MarshalIndent(a, "", " ")
|
||||
jb, _ := json.MarshalIndent(b, "", " ")
|
||||
if string(ja) != string(jb) {
|
||||
t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleService_Im(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
envs := AssembleService("im", spec, nil)
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty envelopes for service im")
|
||||
}
|
||||
// Every envelope.Name starts with "im "
|
||||
for _, e := range envs {
|
||||
if !strings.HasPrefix(e.Name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", e.Name)
|
||||
}
|
||||
}
|
||||
// Sorted by name
|
||||
for i := 1; i < len(envs); i++ {
|
||||
if envs[i-1].Name > envs[i].Name {
|
||||
t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleService_FilterByAccessToken(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
// Filter to bot-only (--as bot, which corresponds to "tenant")
|
||||
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Every envelope's _meta.access_tokens must contain "bot"
|
||||
for _, e := range envs {
|
||||
found := false
|
||||
for _, t := range e.Meta.AccessTokens {
|
||||
if t == "bot" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("envelope %q does not declare bot access", e.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleAll_AtLeast193(t *testing.T) {
|
||||
envs := AssembleAll(nil)
|
||||
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
|
||||
// embedded meta_data.json directly, so the count is stable across machines.
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
|
||||
}
|
||||
// Spot check: im reactions list should be present
|
||||
found := false
|
||||
for _, e := range envs {
|
||||
if e.Name == "im reactions list" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("im reactions list not found in AssembleAll output")
|
||||
}
|
||||
}
|
||||
|
||||
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
|
||||
// real embedded meta_data.json via the registry package.
|
||||
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
|
||||
t.Helper()
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
t.Fatalf("service %q not found in registry", service)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resKey := strings.Join(resourcePath, ".")
|
||||
res, ok := resources[resKey].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resource %q.%s not found", service, resKey)
|
||||
}
|
||||
methods, _ := res["methods"].(map[string]interface{})
|
||||
m, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
|
||||
}
|
||||
return m
|
||||
}
|
||||
233
internal/schema/lint.go
Normal file
233
internal/schema/lint.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
var validJSONSchemaTypes = map[string]bool{
|
||||
"string": true,
|
||||
"integer": true,
|
||||
"number": true,
|
||||
"boolean": true,
|
||||
"array": true,
|
||||
"object": true,
|
||||
}
|
||||
|
||||
var validAccessTokens = map[string]bool{
|
||||
"user": true,
|
||||
"bot": true,
|
||||
}
|
||||
|
||||
// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice
|
||||
// means the envelope is compliant.
|
||||
func lintEnvelope(env Envelope) []error {
|
||||
var errs []error
|
||||
|
||||
// ---- L1: structural ----
|
||||
if env.Name == "" {
|
||||
errs = append(errs, errors.New("L1: name must not be empty"))
|
||||
}
|
||||
if env.InputSchema == nil {
|
||||
errs = append(errs, errors.New("L1: inputSchema must not be nil"))
|
||||
} else {
|
||||
if env.InputSchema.Type != "object" {
|
||||
errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type))
|
||||
}
|
||||
if env.InputSchema.Properties == nil {
|
||||
errs = append(errs, errors.New("L1: inputSchema.properties must not be nil"))
|
||||
}
|
||||
}
|
||||
if env.OutputSchema == nil {
|
||||
errs = append(errs, errors.New("L1: outputSchema must not be nil"))
|
||||
} else {
|
||||
if env.OutputSchema.Type != "object" {
|
||||
errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type))
|
||||
}
|
||||
}
|
||||
if env.Meta == nil {
|
||||
errs = append(errs, errors.New("L1: _meta must not be nil"))
|
||||
// Cannot continue meta-dependent checks
|
||||
return errs
|
||||
}
|
||||
if env.Meta.EnvelopeVersion != "1.0" {
|
||||
errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion))
|
||||
}
|
||||
|
||||
// L1: validate every Property type recursively
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
validatePropertyTypes(env.InputSchema.Properties, &errs)
|
||||
}
|
||||
if env.OutputSchema != nil && env.OutputSchema.Properties != nil {
|
||||
validatePropertyTypes(env.OutputSchema.Properties, &errs)
|
||||
}
|
||||
|
||||
// ---- L2: type-level consistency ----
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
// Walk the whole property tree so format/min-max checks reach leaf
|
||||
// fields nested under the params/data wrapper.
|
||||
walkForL2(env.InputSchema.Properties, &errs)
|
||||
// Top-level required keys must exist in top-level properties.
|
||||
for _, r := range env.InputSchema.Required {
|
||||
if _, ok := env.InputSchema.Properties.Map[r]; !ok {
|
||||
errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- L3: cross-field self-consistency ----
|
||||
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
if env.Meta.Danger != dangerExpected {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
|
||||
}
|
||||
|
||||
// `yes` lives at inputSchema.properties.yes (sibling of params/data),
|
||||
// injected only for risk == RiskHighRiskWrite.
|
||||
hasYes := false
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
_, hasYes = env.InputSchema.Properties.Map["yes"]
|
||||
}
|
||||
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
if hasYes != wantYes {
|
||||
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
|
||||
}
|
||||
|
||||
if len(env.Meta.AccessTokens) == 0 {
|
||||
errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty"))
|
||||
}
|
||||
for _, t := range env.Meta.AccessTokens {
|
||||
if !validAccessTokens[t] {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t))
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// walkForL2 recursively applies per-field L2 checks (format:binary on
|
||||
// non-string; minimum>=maximum) plus the sub-object required-exists invariant.
|
||||
// Required only matters on object-typed Properties (e.g. the params / data
|
||||
// wrappers); leaf scalars ignore it.
|
||||
func walkForL2(props *OrderedProps, errs *[]error) {
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range props.Order {
|
||||
p := props.Map[k]
|
||||
if p.Format == "binary" && p.Type != "string" {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type))
|
||||
}
|
||||
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
|
||||
}
|
||||
if len(p.Required) > 0 && p.Properties != nil {
|
||||
for _, r := range p.Required {
|
||||
if _, ok := p.Properties.Map[r]; !ok {
|
||||
*errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Properties != nil {
|
||||
walkForL2(p.Properties, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validatePropertyTypes walks an OrderedProps tree and asserts:
|
||||
// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties)
|
||||
// - array Properties have Items
|
||||
//
|
||||
// Errors are appended to *errs.
|
||||
func validatePropertyTypes(props *OrderedProps, errs *[]error) {
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range props.Order {
|
||||
p := props.Map[k]
|
||||
if p.Type != "" && !validJSONSchemaTypes[p.Type] {
|
||||
*errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type))
|
||||
}
|
||||
if p.Type == "array" && p.Items == nil {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k))
|
||||
}
|
||||
if p.Properties != nil {
|
||||
validatePropertyTypes(p.Properties, errs)
|
||||
}
|
||||
// Validate the array-element schema itself, not only its child
|
||||
// properties — a primitive element with an invalid type (e.g.
|
||||
// `items.type = "list"`) would otherwise slip past lint.
|
||||
if p.Items != nil {
|
||||
validateItemSchema(k, p.Items, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateItemSchema checks a single array element schema for invalid types,
|
||||
// then recurses into any further nested properties/items.
|
||||
func validateItemSchema(parentKey string, item *Property, errs *[]error) {
|
||||
if item.Type != "" && !validJSONSchemaTypes[item.Type] {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type))
|
||||
}
|
||||
if item.Type == "array" && item.Items == nil {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey))
|
||||
}
|
||||
if item.Properties != nil {
|
||||
validatePropertyTypes(item.Properties, errs)
|
||||
}
|
||||
if item.Items != nil {
|
||||
validateItemSchema(parentKey, item.Items, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// coverageBaseline is the per-metric warn threshold for L4 coverage checks.
|
||||
// If the measured rate drops below the baseline, t.Logf emits a warning but
|
||||
// does NOT fail the test. Adjust these constants upward as meta_data quality
|
||||
// improves over time.
|
||||
var coverageBaseline = map[string]float64{
|
||||
"description": 0.99,
|
||||
"scopes": 1.00,
|
||||
"doc_url": 0.98,
|
||||
"risk": 0.96,
|
||||
}
|
||||
|
||||
// measureCoverage returns the non-empty rate for each tracked metric.
|
||||
func measureCoverage(envs []Envelope) map[string]float64 {
|
||||
if len(envs) == 0 {
|
||||
return map[string]float64{
|
||||
"description": 0,
|
||||
"scopes": 0,
|
||||
"doc_url": 0,
|
||||
"risk": 0,
|
||||
}
|
||||
}
|
||||
total := float64(len(envs))
|
||||
var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64
|
||||
for _, e := range envs {
|
||||
if e.Description != "" {
|
||||
descNonEmpty++
|
||||
}
|
||||
if e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if len(e.Meta.Scopes) > 0 {
|
||||
scopesNonEmpty++
|
||||
}
|
||||
if e.Meta.DocURL != "" {
|
||||
docURLNonEmpty++
|
||||
}
|
||||
if e.Meta.Risk != "" {
|
||||
riskNonEmpty++
|
||||
}
|
||||
}
|
||||
return map[string]float64{
|
||||
"description": descNonEmpty / total,
|
||||
"scopes": scopesNonEmpty / total,
|
||||
"doc_url": docURLNonEmpty / total,
|
||||
"risk": riskNonEmpty / total,
|
||||
}
|
||||
}
|
||||
379
internal/schema/lint_test.go
Normal file
379
internal/schema/lint_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// validEnvelope builds a baseline valid envelope used as a starting point in
|
||||
// negative tests below.
|
||||
func validEnvelope() Envelope {
|
||||
props := &OrderedProps{Map: map[string]Property{}}
|
||||
return Envelope{
|
||||
Name: "x y z",
|
||||
Description: "ok",
|
||||
InputSchema: &InputSchema{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
},
|
||||
OutputSchema: &OutputSchema{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{Map: map[string]Property{}},
|
||||
},
|
||||
Meta: &Meta{
|
||||
EnvelopeVersion: "1.0",
|
||||
AccessTokens: []string{"user"},
|
||||
Risk: "read",
|
||||
Danger: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_Valid(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L1_StructuralChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
mutate: func(e *Envelope) { e.Name = "" },
|
||||
wantSub: "name",
|
||||
},
|
||||
{
|
||||
name: "nil InputSchema",
|
||||
mutate: func(e *Envelope) { e.InputSchema = nil },
|
||||
wantSub: "inputSchema",
|
||||
},
|
||||
{
|
||||
name: "inputSchema type not object",
|
||||
mutate: func(e *Envelope) { e.InputSchema.Type = "string" },
|
||||
wantSub: "inputSchema.type",
|
||||
},
|
||||
{
|
||||
name: "nil OutputSchema",
|
||||
mutate: func(e *Envelope) { e.OutputSchema = nil },
|
||||
wantSub: "outputSchema",
|
||||
},
|
||||
{
|
||||
name: "nil Meta",
|
||||
mutate: func(e *Envelope) { e.Meta = nil },
|
||||
wantSub: "_meta",
|
||||
},
|
||||
{
|
||||
name: "wrong envelope version",
|
||||
mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" },
|
||||
wantSub: "envelope_version",
|
||||
},
|
||||
{
|
||||
name: "invalid property type",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"x"}
|
||||
e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"}
|
||||
},
|
||||
wantSub: "invalid type",
|
||||
},
|
||||
{
|
||||
name: "array missing items",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"x"}
|
||||
e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items
|
||||
},
|
||||
wantSub: "items",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "format binary on non-string",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"f"}
|
||||
e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"}
|
||||
},
|
||||
wantSub: "format: binary",
|
||||
},
|
||||
{
|
||||
name: "required key not in properties",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Required = []string{"nonexistent"}
|
||||
},
|
||||
wantSub: "required",
|
||||
},
|
||||
{
|
||||
name: "minimum >= maximum",
|
||||
mutate: func(e *Envelope) {
|
||||
min, max := 50.0, 10.0
|
||||
e.InputSchema.Properties.Order = []string{"n"}
|
||||
e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max}
|
||||
},
|
||||
wantSub: "minimum",
|
||||
},
|
||||
{
|
||||
// Regression guard: walkForL2 must recurse into the params/data
|
||||
// sub-objects introduced by the 4-bucket inputSchema, not only the
|
||||
// top-level Properties map.
|
||||
name: "format binary on non-string inside params sub-object",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"params"}
|
||||
e.InputSchema.Properties.Map["params"] = Property{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{
|
||||
Order: []string{"id"},
|
||||
Map: map[string]Property{
|
||||
"id": {Type: "integer", Format: "binary"}, // wrong: binary on integer
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
wantSub: "format: binary",
|
||||
},
|
||||
{
|
||||
name: "sub-object required references missing property",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"data"}
|
||||
e.InputSchema.Properties.Map["data"] = Property{
|
||||
Type: "object",
|
||||
Required: []string{"ghost"}, // not in properties below
|
||||
Properties: &OrderedProps{
|
||||
Order: []string{"real"},
|
||||
Map: map[string]Property{"real": {Type: "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
wantSub: "ghost",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "danger true but risk read",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.Danger = true
|
||||
e.Meta.Risk = "read"
|
||||
},
|
||||
wantSub: "danger",
|
||||
},
|
||||
{
|
||||
name: "high-risk-write without yes",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.Risk = "high-risk-write"
|
||||
e.Meta.Danger = true
|
||||
// no yes injection
|
||||
},
|
||||
wantSub: "yes",
|
||||
},
|
||||
{
|
||||
name: "yes injected but risk not high-risk-write",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"yes"}
|
||||
e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"}
|
||||
},
|
||||
wantSub: "yes",
|
||||
},
|
||||
{
|
||||
name: "empty access_tokens",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.AccessTokens = []string{}
|
||||
},
|
||||
wantSub: "access_tokens",
|
||||
},
|
||||
{
|
||||
name: "invalid access_token value",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.AccessTokens = []string{"admin"}
|
||||
},
|
||||
wantSub: "access_tokens",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureCoverage_Counts(t *testing.T) {
|
||||
envs := []Envelope{
|
||||
{Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}},
|
||||
{Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}},
|
||||
{Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}},
|
||||
}
|
||||
c := measureCoverage(envs)
|
||||
// 2/3 have non-empty description = ~0.667
|
||||
if c["description"] < 0.66 || c["description"] > 0.67 {
|
||||
t.Errorf("description coverage = %v, want ~0.667", c["description"])
|
||||
}
|
||||
// 2/3 have non-empty scopes
|
||||
if c["scopes"] < 0.66 || c["scopes"] > 0.67 {
|
||||
t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"])
|
||||
}
|
||||
// 2/3 have doc_url
|
||||
if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 {
|
||||
t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"])
|
||||
}
|
||||
// 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs)
|
||||
if c["risk"] < 0.66 || c["risk"] > 0.67 {
|
||||
t.Errorf("risk coverage = %v, want ~0.667", c["risk"])
|
||||
}
|
||||
}
|
||||
|
||||
// isKnownDataInconsistency returns true for lint errors that originate from
|
||||
// real meta_data quality issues we still have to ship around in PR-1. With
|
||||
// Task 17b the assembler walks embedded data only, so overlay-induced
|
||||
// inconsistencies (risk-stripping) no longer appear; only the true embedded
|
||||
// meta_data data-quality patterns remain.
|
||||
//
|
||||
// As meta_data quality improves this filter should be tightened/removed so
|
||||
// TestAllEnvelopesPass becomes a hard gate again.
|
||||
func isKnownDataInconsistency(msg string) bool {
|
||||
switch {
|
||||
case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`):
|
||||
// Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query,
|
||||
// drive.user.subscription, mail.user_mailbox.event.subscribe) where
|
||||
// `risk="write"` but `danger` is missing (defaults to false). Needs a
|
||||
// meta_data fix to set danger=true on these write methods.
|
||||
return true
|
||||
case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`):
|
||||
// Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event,
|
||||
// drive.metas.batch_query, mail.user_mailbox.templates.create) where
|
||||
// `danger=true` but `risk` is missing (defaults to "read"). Needs a
|
||||
// meta_data fix to set the proper risk level on these methods.
|
||||
return true
|
||||
case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"):
|
||||
// meta_data sets min == max on some fields (e.g.
|
||||
// mail.user_mailbox.event.subscribe.event_type), which the lint reads
|
||||
// as min >= max. Real fix is in meta_data.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAllEnvelopesPass(t *testing.T) {
|
||||
failCount := 0
|
||||
knownWarnings := 0
|
||||
knownEnvelopes := map[string]bool{}
|
||||
// Use embedded data only so the gate is deterministic across machines
|
||||
// (matches Task 17b: envelope assembly is overlay-independent).
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
envs := AssembleService(svc, spec, nil)
|
||||
for _, env := range envs {
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
continue
|
||||
}
|
||||
var realErrs []error
|
||||
for _, e := range errs {
|
||||
if isKnownDataInconsistency(e.Error()) {
|
||||
t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e)
|
||||
knownWarnings++
|
||||
knownEnvelopes[env.Name] = true
|
||||
continue
|
||||
}
|
||||
realErrs = append(realErrs, e)
|
||||
}
|
||||
if len(realErrs) > 0 {
|
||||
for _, e := range realErrs {
|
||||
t.Errorf("%s: %v", env.Name, e)
|
||||
}
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes))
|
||||
if failCount > 0 {
|
||||
t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount)
|
||||
}
|
||||
|
||||
// L4 coverage report (warn-only via t.Logf)
|
||||
all := AssembleAll(nil)
|
||||
c := measureCoverage(all)
|
||||
for metric, rate := range c {
|
||||
baseline := coverageBaseline[metric]
|
||||
if rate < baseline {
|
||||
t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
||||
} else {
|
||||
t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
internal/schema/path.go
Normal file
30
internal/schema/path.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
|
||||
// slice of path segments. It accepts two equivalent forms:
|
||||
//
|
||||
// lark-cli schema im.messages.reply -> single arg, split on "."
|
||||
// lark-cli schema im messages reply -> multiple args, used as-is
|
||||
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
|
||||
// arguments individually if your shell needs it. Nested resources keep their
|
||||
// internal dots (e.g. "chat.members").
|
||||
//
|
||||
// Returns nil for zero args (bare invocation).
|
||||
func ParsePath(args []string) []string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if strings.Contains(args[0], ".") {
|
||||
return strings.Split(args[0], ".")
|
||||
}
|
||||
return []string{args[0]}
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
34
internal/schema/path_test.go
Normal file
34
internal/schema/path_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"empty args -> nil", nil, nil},
|
||||
{"empty slice -> nil", []string{}, nil},
|
||||
{"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}},
|
||||
{"single no-dot", []string{"im"}, []string{"im"}},
|
||||
{"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}},
|
||||
{"two args", []string{"im", "messages"}, []string{"im", "messages"}},
|
||||
{"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}},
|
||||
{"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParsePath(tt.args)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
163
internal/schema/types.go
Normal file
163
internal/schema/types.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||
type Envelope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema *InputSchema `json:"inputSchema"`
|
||||
OutputSchema *OutputSchema `json:"outputSchema"`
|
||||
Meta *Meta `json:"_meta"`
|
||||
}
|
||||
|
||||
// InputSchema is JSON Schema Draft 2020-12 flattened.
|
||||
//
|
||||
// Required is intentionally rendered (no omitempty) so the envelope shape
|
||||
// stays stable for AI consumers — an empty []string means "no required
|
||||
// fields" rather than "schema is missing the field".
|
||||
type InputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Properties *OrderedProps `json:"properties"`
|
||||
}
|
||||
|
||||
// OutputSchema wraps responseBody into a JSON Schema object.
|
||||
type OutputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties *OrderedProps `json:"properties"`
|
||||
}
|
||||
|
||||
// Property is one field's JSON Schema shape, recursive.
|
||||
//
|
||||
// Required is used when Property describes a nested object (e.g. the
|
||||
// "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"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Example interface{} `json:"example,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the Lark-specific extension namespace.
|
||||
type Meta struct {
|
||||
EnvelopeVersion string `json:"envelope_version"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RequiredScopes []string `json:"required_scopes"`
|
||||
AccessTokens []string `json:"access_tokens"`
|
||||
Danger bool `json:"danger"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
Affordance *Affordance `json:"affordance,omitempty"`
|
||||
}
|
||||
|
||||
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
|
||||
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"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one example entry.
|
||||
type AffordanceCase struct {
|
||||
Title string `json:"title"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
}
|
||||
|
||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
||||
// It is used wherever JSON output must reflect meta_data.json's natural field
|
||||
// order rather than Go's default alphabetical map encoding.
|
||||
type OrderedProps struct {
|
||||
Order []string
|
||||
Map map[string]Property
|
||||
}
|
||||
|
||||
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
|
||||
// Map has entries, fall back to alphabetical key order over Map so callers
|
||||
// that only populated Map (no explicit ordering) still see their fields.
|
||||
func (o *OrderedProps) MarshalJSON() ([]byte, error) {
|
||||
if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
keys := o.Order
|
||||
if len(keys) == 0 {
|
||||
keys = make([]string, 0, len(o.Map))
|
||||
for k := range o.Map {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
keyJSON, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal key %q: %w", k, err)
|
||||
}
|
||||
buf.Write(keyJSON)
|
||||
buf.WriteByte(':')
|
||||
valJSON, err := json.Marshal(o.Map[k])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal value for %q: %w", k, err)
|
||||
}
|
||||
buf.Write(valJSON)
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses an object preserving key order via json.Decoder.Token().
|
||||
// Used for round-tripping in tests (and future golden update flows).
|
||||
func (o *OrderedProps) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||
return fmt.Errorf("expected object, got %v", tok)
|
||||
}
|
||||
o.Order = nil
|
||||
o.Map = make(map[string]Property)
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := keyTok.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected string key, got %v", keyTok)
|
||||
}
|
||||
var prop Property
|
||||
if err := dec.Decode(&prop); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Order = append(o.Order, key)
|
||||
o.Map[key] = prop
|
||||
}
|
||||
if _, err := dec.Token(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/schema/types_test.go
Normal file
58
internal/schema/types_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。
|
||||
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
|
||||
op := &OrderedProps{
|
||||
Order: []string{"z_first", "a_second", "m_third"},
|
||||
Map: map[string]Property{
|
||||
"z_first": {Type: "string"},
|
||||
"a_second": {Type: "integer"},
|
||||
"m_third": {Type: "boolean"},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}`
|
||||
if got != want {
|
||||
t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProps_MarshalJSON_Empty(t *testing.T) {
|
||||
op := &OrderedProps{Order: nil, Map: nil}
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(b) != "{}" {
|
||||
t.Errorf("empty OrderedProps should marshal to {}, got: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) {
|
||||
in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`)
|
||||
var op OrderedProps
|
||||
if err := json.Unmarshal(in, &op); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if len(op.Order) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(op.Order))
|
||||
}
|
||||
if op.Order[0] != "first" || op.Order[1] != "second" {
|
||||
t.Errorf("unmarshal lost order: got %v", op.Order)
|
||||
}
|
||||
if op.Map["first"].Type != "string" {
|
||||
t.Errorf("first.type mismatch")
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,12 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
// Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles)
|
||||
// are in updater_unix.go and updater_windows.go.
|
||||
//
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
|
||||
// / RestoreAvailableOverride for testing.
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsUpdateOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
|
||||
@@ -153,12 +153,27 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// RunSkillsUpdate installs skills, trying the .well-known source first and
|
||||
// falling back to the GitHub repo on failure or timeout.
|
||||
func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
if u.SkillsUpdateOverride != nil {
|
||||
return u.SkillsUpdateOverride()
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListGlobalSkills() *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", nameList)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
@@ -167,6 +182,28 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsListGlobal() *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "ls", "-g")
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult {
|
||||
args := []string{"-y", "skills", "add", source, "-s"}
|
||||
args = append(args, nameList...)
|
||||
args = append(args, "-g", "-y")
|
||||
return u.runSkillsCommand(args...)
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||
if u.SkillsCommandOverride != nil {
|
||||
return u.SkillsCommandOverride(args...)
|
||||
}
|
||||
r := &NpmResult{}
|
||||
npxPath, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
@@ -175,7 +212,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
|
||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
||||
cmd.Stdout = &r.Stdout
|
||||
cmd.Stderr = &r.Stderr
|
||||
r.Err = cmd.Run()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
|
||||
t.Fatal("VerifyBinary(empty output) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
run func(*Updater) *NpmResult
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "list official primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn --list",
|
||||
},
|
||||
{
|
||||
name: "list global",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsListGlobal()
|
||||
},
|
||||
want: "-y skills ls -g",
|
||||
},
|
||||
{
|
||||
name: "install skill primary",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"})
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell script")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "npx")
|
||||
logPath := filepath.Join(dir, "npx.log")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
result := tt.run(New())
|
||||
if result.Err != nil {
|
||||
t.Fatalf("command err = %v, want nil", result.Err)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) != tt.want {
|
||||
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
|
||||
r.Err = fmt.Errorf("primary failed")
|
||||
return r
|
||||
}
|
||||
r.Stdout.WriteString("lark-calendar\n")
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := updater.ListOfficialSkills()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
|
||||
}
|
||||
if !strings.Contains(called[1], "larksuite/cli --list") {
|
||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,46 +3,29 @@
|
||||
|
||||
package skillscheck
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice
|
||||
// when the local stamp records a version that does not match
|
||||
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
|
||||
// zero network, zero subprocess — only a local stamp file read.
|
||||
import "strings"
|
||||
|
||||
// Init runs the synchronous skills version check. Stores a StaleNotice when
|
||||
// the local skills state records a version that does not match currentVersion.
|
||||
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
|
||||
// subprocess — only a local state file read.
|
||||
//
|
||||
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
|
||||
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
|
||||
//
|
||||
// Failure modes (all → no notice, no nag):
|
||||
// - shouldSkip rule met
|
||||
// - ReadStamp returns an I/O error other than ENOENT
|
||||
// - Stamp matches currentVersion (in-sync)
|
||||
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
|
||||
// opt into drift tracking; npx-only installs are intentionally silent.
|
||||
func Init(currentVersion string) {
|
||||
// Clear any stale notice from a prior call so early returns below
|
||||
// (skip rules / read errors / cold start / in-sync) leave pending == nil
|
||||
// instead of preserving a stale value from a previous Init invocation.
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
stamp, err := ReadStamp()
|
||||
if err != nil {
|
||||
// Fail closed — don't nag for a transient FS problem.
|
||||
version, ok := ReadSyncedVersion()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if stamp == "" {
|
||||
// Cold start: the stamp is written exclusively by `lark-cli update`
|
||||
// (runSkillsAndStamp). Users who installed skills via
|
||||
// `npx skills add larksuite/cli -g` have no stamp yet — they must
|
||||
// not be nagged with "skills not installed", since the on-disk
|
||||
// skills directory may already be fully populated.
|
||||
return
|
||||
}
|
||||
if stamp == currentVersion {
|
||||
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: stamp, // guaranteed non-empty under the new contract
|
||||
Current: version,
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ func resetPending(t *testing.T) {
|
||||
func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
@@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
|
||||
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("v1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
@@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
// Even with an empty config dir (no stamp), DEV version should skip
|
||||
// the check entirely and never emit a notice.
|
||||
Init("DEV")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
|
||||
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Make the stamp path a directory so vfs.ReadFile returns a
|
||||
// non-ENOENT I/O error.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
// Package skillscheck verifies that the locally installed lark-cli
|
||||
// skills are in sync with the running binary version, by comparing
|
||||
// the current binary version against a stamp file written when skills
|
||||
// are last synced (by `lark-cli update`). On mismatch it stores a
|
||||
// notice for injection into JSON envelopes via output.PendingNotice.
|
||||
// the current binary version against skills-state.json. On mismatch it
|
||||
// stores a notice for injection into JSON envelopes via output.PendingNotice.
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
@@ -26,8 +25,7 @@ type StaleNotice struct {
|
||||
// Message returns a single-line, AI-agent-parseable description of the
|
||||
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
|
||||
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
|
||||
// non-empty because Init only emits a StaleNotice for the drift case
|
||||
// (stamp present and != binary version).
|
||||
// non-empty because Init only emits a StaleNotice for the drift case.
|
||||
func (s *StaleNotice) Message() string {
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const stampFile = "skills.stamp"
|
||||
|
||||
// stampPath returns ~/.lark-cli/skills.stamp.
|
||||
// Uses the BASE config dir (not workspace-aware) because skills install
|
||||
// globally via `npx -g`; per-workspace tracking would produce false
|
||||
// drift signals when switching workspaces.
|
||||
func stampPath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stampFile)
|
||||
}
|
||||
|
||||
// ReadStamp returns the version recorded in the stamp file. Returns
|
||||
// ("", nil) when the file does not exist (interpreted as "never synced").
|
||||
// Other I/O errors are returned as-is so callers can fail closed.
|
||||
func ReadStamp() (string, error) {
|
||||
data, err := vfs.ReadFile(stampPath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// WriteStamp records `version` as the last successfully synced skills
|
||||
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
|
||||
// the base config directory if it does not exist.
|
||||
func WriteStamp(version string) error {
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadStamp_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
got, err := ReadStamp()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_Normal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStamp_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadStamp()
|
||||
if err != nil || got != "" {
|
||||
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_CreatesDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatalf("WriteStamp() = %v, want nil", err)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(got) != "1.0.21" {
|
||||
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_OverwritesExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.20"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := ReadStamp()
|
||||
if got != "1.0.21" {
|
||||
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteStamp("1.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
|
||||
if string(raw) != "1.0.21" {
|
||||
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
|
||||
// when the base config dir cannot be created (parent path is a regular file).
|
||||
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
blocker := filepath.Join(tmp, "blocker")
|
||||
// Create a regular file where MkdirAll wants to create a directory.
|
||||
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
|
||||
|
||||
if err := WriteStamp("1.0.21"); err == nil {
|
||||
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
|
||||
}
|
||||
}
|
||||
92
internal/skillscheck/state.go
Normal file
92
internal/skillscheck/state.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFile = "skills-state.json"
|
||||
)
|
||||
|
||||
var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func statePath() string {
|
||||
return filepath.Join(core.GetBaseConfigDir(), stateFile)
|
||||
}
|
||||
|
||||
func ReadState() (*SkillsState, bool, error) {
|
||||
data, err := vfs.ReadFile(statePath())
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
|
||||
}
|
||||
|
||||
var state SkillsState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
|
||||
}
|
||||
return &state, true, nil
|
||||
}
|
||||
|
||||
func WriteState(state SkillsState) error {
|
||||
state.ensureNonNilSlices()
|
||||
|
||||
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func ReadSyncedVersion() (string, bool) {
|
||||
state, ok, err := ReadState()
|
||||
if err != nil || !ok || state.Version == "" {
|
||||
return "", false
|
||||
}
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
}
|
||||
if s.UpdatedSkills == nil {
|
||||
s.UpdatedSkills = []string{}
|
||||
}
|
||||
if s.AddedOfficialSkills == nil {
|
||||
s.AddedOfficialSkills = []string{}
|
||||
}
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
}
|
||||
139
internal/skillscheck/state_test.go
Normal file
139
internal/skillscheck/state_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadState_Missing(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ReadState() ok = true, want false for missing file")
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadState_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
want := SkillsState{
|
||||
Version: "1.2.3",
|
||||
OfficialSkills: []string{"lark-doc", "lark-im"},
|
||||
UpdatedSkills: []string{"lark-doc"},
|
||||
AddedOfficialSkills: []string{"lark-task"},
|
||||
SkippedDeletedSkills: []string{"custom-skill"},
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, ok, err := ReadState()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadState() err = %v, want nil", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("ReadState() ok = false, want true")
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("ReadState() state = nil, want state")
|
||||
}
|
||||
if !reflect.DeepEqual(*got, want) {
|
||||
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadState_CorruptStateUnreadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(`{"version":`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if !errors.Is(err, ErrUnreadableState) {
|
||||
t.Fatalf("ReadState() err = %v, want ErrUnreadableState", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("ReadState() ok = true, want false")
|
||||
}
|
||||
if state != nil {
|
||||
t.Fatalf("ReadState() state = %#v, want nil", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
state := SkillsState{
|
||||
Version: "1.2.3",
|
||||
UpdatedAt: "2026-05-18T10:00:00Z",
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
t.Fatalf("WriteState() err = %v, want nil", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got SkillsState
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("written state is invalid JSON: %v", err)
|
||||
}
|
||||
if got.Version != state.Version {
|
||||
t.Fatalf("version = %q, want %q", got.Version, state.Version)
|
||||
}
|
||||
if got.OfficialSkills == nil {
|
||||
t.Fatal("official_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.UpdatedSkills == nil {
|
||||
t.Fatal("updated_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.AddedOfficialSkills == nil {
|
||||
t.Fatal("added_skills decoded as nil, want empty slice")
|
||||
}
|
||||
if got.SkippedDeletedSkills == nil {
|
||||
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSyncedVersionFromState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
if got, ok := ReadSyncedVersion(); ok || got != "" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
|
||||
}
|
||||
if err := WriteState(SkillsState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := ReadSyncedVersion(); ok || got != "" {
|
||||
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
|
||||
}
|
||||
}
|
||||
399
internal/skillscheck/sync.go
Normal file
399
internal/skillscheck/sync.go
Normal file
@@ -0,0 +1,399 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
var (
|
||||
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
||||
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||
)
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
StateReadable bool
|
||||
Force bool
|
||||
}
|
||||
|
||||
type SyncPlan struct {
|
||||
Version string
|
||||
OfficialSkills []string
|
||||
ToUpdate []string
|
||||
Added []string
|
||||
SkippedDeleted []string
|
||||
}
|
||||
|
||||
func stripANSI(s string) string {
|
||||
return ansiPattern.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func ParseSkillsList(text string) []string {
|
||||
text = stripANSI(text)
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
// Detect format type
|
||||
hasGlobalSkills := strings.Contains(text, "Global Skills")
|
||||
hasAvailableSkills := strings.Contains(text, "Available Skills")
|
||||
|
||||
if hasGlobalSkills {
|
||||
// Format 1: locally installed skills list from "npx -y skills ls -g"
|
||||
return parseGlobalSkillsList(lines)
|
||||
} else if hasAvailableSkills {
|
||||
// Format 2: official skills list from "npx -y skills add ... --list"
|
||||
return parseOfficialSkillsList(lines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Skip header
|
||||
if strings.HasPrefix(trimmed, "Global Skills") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "Tip:") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip indented lines (Agents: ...)
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract skill name, format is typically "skill-name /path/to/skill"
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate := parts[0]
|
||||
|
||||
// Validate and add
|
||||
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(candidate) {
|
||||
continue
|
||||
}
|
||||
if at := strings.Index(candidate, "@"); at > 0 {
|
||||
candidate = candidate[:at]
|
||||
}
|
||||
seen[candidate] = true
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
|
||||
func parseOfficialSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
inAvailableSection := false
|
||||
|
||||
for _, line := range lines {
|
||||
// Check if we've reached the "Available Skills" section
|
||||
if strings.Contains(line, "Available Skills") {
|
||||
inAvailableSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
if !inAvailableSection {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines containing "│", e.g. " │ lark-approval "
|
||||
if strings.Contains(line, "│") {
|
||||
// Remove all "│" characters and spaces, extract the first valid token in order
|
||||
parts := strings.FieldsFunc(line, func(r rune) bool {
|
||||
return r == '│' || r == ' '
|
||||
})
|
||||
|
||||
if len(parts) > 0 {
|
||||
candidate := parts[0]
|
||||
// Check if it's a valid official skill name
|
||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: official,
|
||||
Added: []string{},
|
||||
SkippedDeleted: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
installedOfficial := intersection(input.LocalSkills, officialSet)
|
||||
|
||||
previousOfficial := []string{}
|
||||
if input.StateReadable && input.PreviousState != nil {
|
||||
previousOfficial = input.PreviousState.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
|
||||
newAddedOfficial := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
newAddedOfficial = append(newAddedOfficial, skill)
|
||||
}
|
||||
}
|
||||
|
||||
updateSet := toSet(installedOfficial)
|
||||
for _, skill := range newAddedOfficial {
|
||||
updateSet[skill] = true
|
||||
}
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newAddedOfficial),
|
||||
SkippedDeleted: skipped,
|
||||
}
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
Action string
|
||||
Official []string
|
||||
Updated []string
|
||||
Added []string
|
||||
SkippedDeleted []string
|
||||
Failed []string
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local := []string{}
|
||||
localResult := opts.Runner.ListGlobalSkills()
|
||||
if localResult != nil && localResult.Err == nil {
|
||||
local = ParseSkillsList(localResult.Stdout.String())
|
||||
}
|
||||
|
||||
// --- Step 3: Read previous state ---
|
||||
previous, readable, err := ReadState()
|
||||
if err != nil {
|
||||
readable = false
|
||||
previous = nil
|
||||
}
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
Official: plan.OfficialSkills,
|
||||
Updated: plan.ToUpdate,
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
return fallbackFullInstall(opts, resultDetail(installResult), official)
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
|
||||
// when incremental sync is not possible. On success it writes a state file so that
|
||||
// subsequent syncs can use incremental mode. When official is non-nil the state
|
||||
// records the full official list; otherwise a minimal state (version only) is
|
||||
// written to break the fallback loop.
|
||||
func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult {
|
||||
installResult := opts.Runner.InstallAllSkills()
|
||||
if installResult == nil {
|
||||
return &SyncResult{
|
||||
Action: "fallback_failed",
|
||||
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
}
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
return &SyncResult{
|
||||
Action: "fallback_failed",
|
||||
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
||||
Detail: reason + "\n" + resultDetail(installResult),
|
||||
Force: opts.Force,
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
SkippedDeletedSkills: []string{},
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if writeErr := WriteState(state); writeErr != nil {
|
||||
return &SyncResult{
|
||||
Action: "fallback_synced",
|
||||
Official: official,
|
||||
Updated: official,
|
||||
Added: official,
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
||||
Force: opts.Force,
|
||||
}
|
||||
}
|
||||
|
||||
return &SyncResult{
|
||||
Action: "fallback_synced",
|
||||
Official: official,
|
||||
Updated: official,
|
||||
Added: official,
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
}
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
|
||||
parts = append(parts, output)
|
||||
}
|
||||
if result.Err != nil {
|
||||
parts = append(parts, result.Err.Error())
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func uniqueSorted(values []string) []string {
|
||||
return sortedKeys(toSet(values))
|
||||
}
|
||||
|
||||
func toSet(values []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// result = { x | x ∈ values ∧ x ∈ allowed }
|
||||
func intersection(values []string, allowed map[string]bool) []string {
|
||||
out := map[string]bool{}
|
||||
for _, value := range values {
|
||||
if allowed[value] {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(out)
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]bool) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
517
internal/skillscheck/sync_test.go
Normal file
517
internal/skillscheck/sync_test.go
Normal file
@@ -0,0 +1,517 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
func TestParseSkillsListIgnoresUnsupportedFormat(t *testing.T) {
|
||||
input := `Installed skills:
|
||||
- lark-calendar
|
||||
- lark-mail
|
||||
lark-im
|
||||
custom-skill
|
||||
lark-base@1.0.0
|
||||
lark-cli-harness:dev@0.1.0
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("ParseSkillsList() = %#v, want empty result for unsupported format", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
lark-approval ~/.agents/skills/lark-approval
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
lark-attendance ~/.agents/skills/lark-attendance
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
lark-calendar ~/.agents/skills/lark-calendar
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
dogfood ~/.hermes/skills/dogfood
|
||||
Agents: Hermes Agent
|
||||
yuanbao ~/.hermes/skills/yuanbao
|
||||
Agents: Hermes Agent
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"dogfood", "lark-approval", "lark-attendance", "lark-base", "lark-calendar", "yuanbao"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
input := "\x1b[1mGlobal Skills\x1b[0m\n\n" +
|
||||
"\x1b[36mlark-calendar\x1b[0m \x1b[38;5;102m~/.agents/skills/lark-calendar\x1b[0m\n" +
|
||||
" \x1b[38;5;102mAgents:\x1b[0m TRAE CN, TRAE +3 more\n" +
|
||||
"\x1b[36mdogfood\x1b[0m \x1b[38;5;102m~/.hermes/skills/dogfood\x1b[0m\n" +
|
||||
" \x1b[38;5;102mAgents:\x1b[0m Hermes Agent\n" +
|
||||
"\nTip: Use the -y flag to run in non-interactive mode (for CI and AI agents).\n"
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"dogfood", "lark-calendar"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (ANSI Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar", "lark-custom"},
|
||||
PreviousState: previous,
|
||||
StateReadable: true,
|
||||
Force: false,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{"lark-new"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
|
||||
}
|
||||
|
||||
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar"},
|
||||
StateReadable: false,
|
||||
Force: false,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
got := PlanSync(SyncInput{
|
||||
Version: "1.0.33",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
|
||||
LocalSkills: []string{"lark-calendar"},
|
||||
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
|
||||
StateReadable: true,
|
||||
Force: true,
|
||||
})
|
||||
|
||||
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, got.Added, []string{})
|
||||
assertStrings(t, got.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialOut string
|
||||
globalOut string
|
||||
officialErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Available Skills\n")
|
||||
for _, name := range names {
|
||||
b.WriteString("│ ")
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Global Skills\n\n")
|
||||
for _, name := range names {
|
||||
b.WriteString(name)
|
||||
b.WriteString(" ~/.agents/skills/")
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n Agents: Claude Code\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSkill(nameList []string) *selfupdate.NpmResult {
|
||||
f.installed = append(f.installed, nameList)
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
f.installedAll++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installAllErr
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := WriteState(SkillsState{
|
||||
Version: "1.0.30",
|
||||
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
||||
UpdatedAt: "2026-05-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Runner: runner,
|
||||
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatalf("SyncSkills() err = nil, want error")
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "full skills install failed") {
|
||||
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalErr: fmt.Errorf("global list failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1", len(runner.installed))
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatalf("SyncSkills() err = nil, want error")
|
||||
}
|
||||
if !strings.Contains(result.Detail, "incremental boom") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "full skills install failed") {
|
||||
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatalf("SyncSkills() err = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{})
|
||||
assertStrings(t, state.UpdatedSkills, []string{})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-calendar", "lark-mail"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
if !strings.Contains(result.Detail, "incremental boom") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result1.Action != "fallback_synced" {
|
||||
t.Fatalf("first sync: action = %q, want fallback_synced", result1.Action)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after first sync = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
t.Fatalf("second sync: action = %q, want synced (no fallback loop)", result2.Action)
|
||||
}
|
||||
if runner2.installedAll != 0 {
|
||||
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.40",
|
||||
"version": "1.0.41",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -73,6 +73,9 @@ var urlPathToType = []struct {
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/drive/file/", "file"},
|
||||
{"/drive/shr/", "folder"},
|
||||
{"/chat/drive/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
|
||||
@@ -28,6 +28,9 @@ func TestParseResourceURL(t *testing.T) {
|
||||
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
|
||||
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
|
||||
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
|
||||
{"file via /drive/file/", "https://feishu.doubao.com/drive/file/boxcnABC", "file", "boxcnABC", true},
|
||||
{"folder via /chat/drive/", "https://feishu.doubao.com/chat/drive/fldcnABC", "folder", "fldcnABC", true},
|
||||
{"folder via /drive/shr/", "https://feishu.doubao.com/drive/shr/fldcnABC", "folder", "fldcnABC", true},
|
||||
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
|
||||
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
@@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
|
||||
@@ -109,6 +109,45 @@ func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDoubaoDriveFileURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDoubaoChatDriveFolderURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/chat/drive/fldcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectValidate_ValidDoubaoDriveShareFolderURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
err := DriveInspect.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
|
||||
@@ -235,6 +274,82 @@ func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
|
||||
if !ok || len(reqDocs) != 1 {
|
||||
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
|
||||
}
|
||||
doc, _ := reqDocs[0].(map[string]interface{})
|
||||
if doc["doc_token"] != "boxcnABC" {
|
||||
t.Errorf("doc_token = %v, want boxcnABC", doc["doc_token"])
|
||||
}
|
||||
if doc["doc_type"] != "file" {
|
||||
t.Errorf("doc_type = %v, want file", doc["doc_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC")
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||
dry := DriveInspect.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run: %v", err)
|
||||
}
|
||||
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
|
||||
if !ok || len(reqDocs) != 1 {
|
||||
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
|
||||
}
|
||||
doc, _ := reqDocs[0].(map[string]interface{})
|
||||
if doc["doc_token"] != "fldcnABC" {
|
||||
t.Errorf("doc_token = %v, want fldcnABC", doc["doc_token"])
|
||||
}
|
||||
if doc["doc_type"] != "folder" {
|
||||
t.Errorf("doc_type = %v, want folder", doc["doc_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestDriveInspectExecute_DocxURL(t *testing.T) {
|
||||
|
||||
124
shortcuts/drive/drive_secure_label.go
Normal file
124
shortcuts/drive/drive_secure_label.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
var DriveSecureLabelList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+secure-label-list",
|
||||
Description: "List secure labels available to the current user",
|
||||
Risk: "read",
|
||||
Scopes: []string{secureLabelReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
{Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 || pageSize > 10 {
|
||||
return output.ErrValidation("--page-size must be between 1 and 10")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List secure labels available to the current user").
|
||||
GET("/open-apis/drive/v2/my_secure_labels").
|
||||
Params(buildSecureLabelListParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPI("GET",
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
buildSecureLabelListParams(runtime),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(data, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DriveSecureLabelUpdate updates the secure label on a Drive file/document.
|
||||
var DriveSecureLabelUpdate = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+secure-label-update",
|
||||
Description: "Update the secure label on a Drive file or document",
|
||||
Risk: "write",
|
||||
Scopes: []string{secureLabelUpdateScope},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
|
||||
{Name: "label-id", Desc: "secure label ID to set", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive secure label").
|
||||
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
|
||||
Set("file_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
data, err := runtime.CallAPI("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
if lang := runtime.Str("lang"); lang != "" {
|
||||
params["lang"] = lang
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
}
|
||||
164
shortcuts/drive/drive_secure_label_test.go
Normal file
164
shortcuts/drive/drive_secure_label_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveSecureLabelList_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_1",
|
||||
"--lang", "zh",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
`"GET"`,
|
||||
`"page_size": 5`,
|
||||
`"page_token": "page_1"`,
|
||||
`"lang": "zh"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--page-size", "11",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "page-size") {
|
||||
t.Fatalf("expected page-size validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "7217780879644737540", "name": "L1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"L1"`) {
|
||||
t.Fatalf("stdout missing label:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
`"PATCH"`,
|
||||
`"docx"`,
|
||||
`"id": "7217780879644737539"`,
|
||||
`"file_token": "doxTok123"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["id"] != "7217780879644737539" {
|
||||
t.Fatalf("id = %v, want label id", body["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 403,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063013, "msg": "Security label downgrade requires approval",
|
||||
},
|
||||
})
|
||||
|
||||
targetURL := "https://example.feishu.cn/docx/doxTok123"
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", targetURL,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 1063013 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
|
||||
t.Fatalf("expected raw API error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
DriveInspect,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
"+inspect",
|
||||
}
|
||||
|
||||
139
shortcuts/minutes/minutes_speaker_replace.go
Normal file
139
shortcuts/minutes/minutes_speaker_replace.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesSpeakerReplaceSpeakerNotFoundCode = 2091001
|
||||
minutesSpeakerReplaceNoEditPermission = 2091005
|
||||
)
|
||||
|
||||
// MinutesSpeakerReplace replaces a speaker in a minute's transcript.
|
||||
var MinutesSpeakerReplace = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+speaker-replace",
|
||||
Description: "Replace a speaker in a minute's transcript (rebind from one user to another)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:minutes:update"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-token", Desc: "minute token", Required: true},
|
||||
{Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true},
|
||||
{Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
if minuteToken == "" {
|
||||
return output.ErrValidation("--minute-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID == "" {
|
||||
return output.ErrValidation("--from-user-id is required")
|
||||
}
|
||||
if _, err := common.ValidateUserID(fromUserID); err != nil {
|
||||
return output.ErrValidation("--from-user-id: %s", err)
|
||||
}
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
if toUserID == "" {
|
||||
return output.ErrValidation("--to-user-id is required")
|
||||
}
|
||||
if _, err := common.ValidateUserID(toUserID); err != nil {
|
||||
return output.ErrValidation("--to-user-id: %s", err)
|
||||
}
|
||||
if fromUserID == toUserID {
|
||||
return output.ErrValidation("--from-user-id and --to-user-id must be different")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"from_user_id": fromUserID,
|
||||
"to_user_id": toUserID,
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"from_user_id": fromUserID,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
|
||||
_, err := runtime.CallAPI(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, fromUserID)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"from_user_id": fromUserID,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch exitErr.Detail.Code {
|
||||
case minutesSpeakerReplaceNoEditPermission:
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "no_edit_permission",
|
||||
Code: minutesSpeakerReplaceNoEditPermission,
|
||||
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken),
|
||||
Hint: "Ask the minute owner for minute edit permission",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
case minutesSpeakerReplaceSpeakerNotFoundCode:
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "speaker_not_found",
|
||||
Code: minutesSpeakerReplaceSpeakerNotFoundCode,
|
||||
Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID),
|
||||
Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
247
shortcuts/minutes/minutes_speaker_replace_test.go
Normal file
247
shortcuts/minutes/minutes_speaker_replace_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const minutesSpeakerReplaceTestToken = "obcnexampleminute"
|
||||
|
||||
func TestMinutesSpeakerReplace_Validate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing minute token",
|
||||
args: []string{"+speaker-replace", "--from-user-id", "ou_a", "--to-user-id", "ou_b", "--as", "user"},
|
||||
wantErr: "required flag(s) \"minute-token\" not set",
|
||||
},
|
||||
{
|
||||
name: "missing from",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"},
|
||||
wantErr: "required flag(s) \"from-user-id\" not set",
|
||||
},
|
||||
{
|
||||
name: "missing to",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--as", "user"},
|
||||
wantErr: "required flag(s) \"to-user-id\" not set",
|
||||
},
|
||||
{
|
||||
name: "invalid from prefix",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
|
||||
wantErr: "--from-user-id",
|
||||
},
|
||||
{
|
||||
name: "invalid to prefix",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
|
||||
wantErr: "--to-user-id",
|
||||
},
|
||||
{
|
||||
name: "from equals to",
|
||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"},
|
||||
wantErr: "must be different",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesSpeakerReplace.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-user-id", "ou_old_speaker",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT method, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSpeakerReplaceTestToken+"/transcript/speaker") {
|
||||
t.Errorf("expected speaker endpoint, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_old_speaker") {
|
||||
t.Errorf("expected from_user_id in body, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-user-id", "ou_old_speaker",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.MinuteToken != minutesSpeakerReplaceTestToken {
|
||||
t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesSpeakerReplaceTestToken)
|
||||
}
|
||||
if envelope.Data.FromUserID != "ou_old_speaker" {
|
||||
t.Errorf("data.from_user_id = %q, want ou_old_speaker", envelope.Data.FromUserID)
|
||||
}
|
||||
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091001,
|
||||
"msg": "speaker not exist",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-user-id", "ou_missing_speaker",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected speaker-not-found error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error detail, got nil")
|
||||
}
|
||||
if exitErr.Detail.Type != "speaker_not_found" {
|
||||
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "Speaker not found") {
|
||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") {
|
||||
t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") {
|
||||
t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091005,
|
||||
"msg": "no edit permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-user-id", "ou_old_speaker",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected no-edit-permission error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error detail, got nil")
|
||||
}
|
||||
if exitErr.Detail.Type != "no_edit_permission" {
|
||||
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
|
||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
94
shortcuts/minutes/minutes_update.go
Normal file
94
shortcuts/minutes/minutes_update.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const minutesUpdateNoEditPermissionCode = 2091005
|
||||
|
||||
// MinutesUpdate updates the title (topic) of a minute.
|
||||
var MinutesUpdate = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+update",
|
||||
Description: "Update a minute's title",
|
||||
Risk: "write",
|
||||
Scopes: []string{"minutes:minutes:update"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-token", Desc: "minute token", Required: true},
|
||||
{Name: "topic", Desc: "new minute title", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
if minuteToken == "" {
|
||||
return output.ErrValidation("--minute-token is required")
|
||||
}
|
||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("topic")) == "" {
|
||||
return output.ErrValidation("--topic is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken))).
|
||||
Body(map[string]interface{}{"topic": runtime.Str("topic")})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
topic := runtime.Str("topic")
|
||||
|
||||
body := map[string]interface{}{
|
||||
"topic": topic,
|
||||
}
|
||||
|
||||
_, err := runtime.CallAPI(http.MethodPatch,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return minutesUpdateError(err, minuteToken)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"topic": topic,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func minutesUpdateError(err error, minuteToken string) error {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode {
|
||||
return err
|
||||
}
|
||||
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "no_edit_permission",
|
||||
Code: minutesUpdateNoEditPermissionCode,
|
||||
Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken),
|
||||
Hint: "Ask the minute owner for minute edit permission",
|
||||
Detail: exitErr.Detail.Detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
154
shortcuts/minutes/minutes_update_test.go
Normal file
154
shortcuts/minutes/minutes_update_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const minutesUpdateTestToken = "obcnexampleminute"
|
||||
|
||||
func TestMinutesUpdate_Validate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing minute token",
|
||||
args: []string{"+update", "--topic", "new title", "--as", "user"},
|
||||
wantErr: "required flag(s) \"minute-token\" not set",
|
||||
},
|
||||
{
|
||||
name: "missing topic",
|
||||
args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"},
|
||||
wantErr: "required flag(s) \"topic\" not set",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
MinutesUpdate.Mount(parent, f)
|
||||
parent.SetArgs(tt.args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
err := parent.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpdate_DryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesUpdate, []string{
|
||||
"+update",
|
||||
"--minute-token", minutesUpdateTestToken,
|
||||
"--topic", "周会纪要",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "PATCH") {
|
||||
t.Errorf("expected PATCH method, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesUpdateTestToken) {
|
||||
t.Errorf("expected PATCH /open-apis/minutes/v1/minutes/<token>, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "周会纪要") {
|
||||
t.Errorf("expected topic in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpdate_Execute(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPatch,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesUpdate, []string{
|
||||
"+update",
|
||||
"--minute-token", minutesUpdateTestToken,
|
||||
"--topic", "新标题",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesUpdate_NoEditPermission(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPatch,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 2091005,
|
||||
"msg": "no edit permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, MinutesUpdate, []string{
|
||||
"+update",
|
||||
"--minute-token", minutesUpdateTestToken,
|
||||
"--topic", "新标题",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected no-edit-permission error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error detail, got nil")
|
||||
}
|
||||
if exitErr.Detail.Type != "no_edit_permission" {
|
||||
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
|
||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) {
|
||||
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
|
||||
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
MinutesSearch,
|
||||
MinutesDownload,
|
||||
MinutesUpload,
|
||||
MinutesUpdate,
|
||||
MinutesSpeakerReplace,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
@@ -264,7 +265,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: multiValue, number, text, color", Required: true},
|
||||
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
|
||||
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
|
||||
},
|
||||
@@ -272,7 +273,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
|
||||
if _, err := validateFilterViewToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateExpectedFlag(runtime.Str("expected"))
|
||||
return validateFilterViewConditionFlags(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
@@ -306,7 +307,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
{Name: "sheet-id", Desc: "sheet ID", Required: true},
|
||||
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
|
||||
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
|
||||
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
|
||||
{Name: "filter-type", Desc: "filter type: multiValue, number, text, color"},
|
||||
{Name: "compare-type", Desc: "comparison operator"},
|
||||
{Name: "expected", Desc: "filter values JSON array"},
|
||||
},
|
||||
@@ -319,10 +320,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
|
||||
!hasNonEmptyStringFlag(runtime, "expected") {
|
||||
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
|
||||
}
|
||||
if s := runtime.Str("expected"); s != "" {
|
||||
return validateExpectedFlag(s)
|
||||
}
|
||||
return nil
|
||||
return validateFilterViewConditionFlags(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateFilterViewToken(runtime)
|
||||
@@ -469,6 +467,55 @@ func validateExpectedFlag(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFilterViewConditionFlags(runtime *common.RuntimeContext) error {
|
||||
filterType := strings.TrimSpace(runtime.Str("filter-type"))
|
||||
if filterType != "" {
|
||||
switch filterType {
|
||||
case "multiValue", "number", "text", "color":
|
||||
case "hiddenValue":
|
||||
return output.ErrValidation("--filter-type hiddenValue is no longer supported by Lark Sheets filter view conditions; use --filter-type multiValue with --expected values to show, and omit --compare-type")
|
||||
default:
|
||||
return output.ErrValidation("--filter-type must be one of multiValue, number, text, color; got %q", filterType)
|
||||
}
|
||||
}
|
||||
|
||||
expected := runtime.Str("expected")
|
||||
if filterType == "multiValue" {
|
||||
if strings.TrimSpace(runtime.Str("compare-type")) != "" {
|
||||
return output.ErrValidation("--compare-type must be omitted when --filter-type multiValue")
|
||||
}
|
||||
values, err := parseExpectedStringArray(expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return output.ErrValidation("--expected must contain at least one value when --filter-type multiValue")
|
||||
}
|
||||
for i, value := range values {
|
||||
if utf8.RuneCountInString(value) > 50000 {
|
||||
return output.ErrValidation("--expected[%d] must be 50000 characters or fewer when --filter-type multiValue", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if expected != "" {
|
||||
return validateExpectedFlag(expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseExpectedStringArray(s string) ([]string, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil, output.ErrValidation("--expected is required when --filter-type multiValue")
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(s), &values); err != nil {
|
||||
return nil, output.ErrValidation("--expected must be a JSON string array when --filter-type multiValue (e.g. [\"A\",\"B\"]), got: %s", s)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if includeConditionID {
|
||||
|
||||
@@ -334,6 +334,104 @@ func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionMultiValueExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
|
||||
"condition": map[string]interface{}{"condition_id": "C", "filter_type": "multiValue"},
|
||||
}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "C", "--filter-type", "multiValue",
|
||||
"--expected", `["A","B"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["filter_type"] != "multiValue" {
|
||||
t.Fatalf("unexpected filter_type: %v", body["filter_type"])
|
||||
}
|
||||
if _, ok := body["compare_type"]; ok {
|
||||
t.Fatalf("multiValue body must omit compare_type: %v", body)
|
||||
}
|
||||
expected, ok := body["expected"].([]interface{})
|
||||
if !ok || len(expected) != 2 || expected[0] != "A" || expected[1] != "B" {
|
||||
t.Fatalf("unexpected expected values: %#v", body["expected"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionRejectsHiddenValue(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "C", "--filter-type", "hiddenValue",
|
||||
"--expected", `["A"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected hiddenValue validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--filter-type hiddenValue is no longer supported") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFilterViewConditionRejectsInvalidMultiValueFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "compare type",
|
||||
args: []string{"--compare-type", "less", "--expected", `["A"]`},
|
||||
wantText: "--compare-type must be omitted",
|
||||
},
|
||||
{
|
||||
name: "empty expected",
|
||||
args: []string{"--expected", `[]`},
|
||||
wantText: "at least one value",
|
||||
},
|
||||
{
|
||||
name: "non-string expected",
|
||||
args: []string{"--expected", `[1]`},
|
||||
wantText: "JSON string array",
|
||||
},
|
||||
{
|
||||
name: "too long expected",
|
||||
args: []string{"--expected", `["` + strings.Repeat("x", 50001) + `"]`},
|
||||
wantText: "50000 characters",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
args := []string{
|
||||
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
|
||||
"--condition-id", "C", "--filter-type", "multiValue",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
args = append(args, "--as", "user")
|
||||
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", tc.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantText) {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
|
||||
@@ -384,6 +482,21 @@ func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFilterViewConditionRejectsHiddenValue(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
|
||||
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "C",
|
||||
"--filter-type", "hiddenValue", "--expected", `["A"]`, "--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected hiddenValue validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--filter-type hiddenValue is no longer supported") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListFilterViewConditions ─────────────────────────────────────────────────
|
||||
|
||||
func TestListFilterViewConditionsDryRun(t *testing.T) {
|
||||
|
||||
@@ -414,6 +414,9 @@ func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, re
|
||||
if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 {
|
||||
result["chapters"] = chapters
|
||||
}
|
||||
if keywords, ok := data["keywords"].([]any); ok && len(keywords) > 0 {
|
||||
result["keywords"] = keywords
|
||||
}
|
||||
}
|
||||
|
||||
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
|
||||
|
||||
@@ -126,6 +126,7 @@ func artifactsStub(token string) *httpmock.Stub {
|
||||
"summary": "Test summary content",
|
||||
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
|
||||
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
|
||||
"keywords": []interface{}{"budget", "roadmap"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,14 +59,12 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
## 查看完整参数定义
|
||||
lark-cli schema calendar.events.create
|
||||
## 创建日程
|
||||
lark-cli calendar events create --calendar-id primary --data '{
|
||||
"summary": "产品评审",
|
||||
"description": "本周分享主题:CLI 架构设计",
|
||||
lark-cli calendar events create \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>"}' \
|
||||
--data '{
|
||||
"summary": "技术分享:CLI 架构设计",
|
||||
"start_time": { "timestamp": "1741586400" },
|
||||
"end_time": { "timestamp": "1741593600" },
|
||||
"location": { "name": "5F-大会议室" },
|
||||
"attendee_ability": "can_modify_event",
|
||||
"reminders": [{ "minutes": 15 }]
|
||||
"end_time": { "timestamp": "1741593600" }
|
||||
}'
|
||||
|
||||
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id)
|
||||
@@ -74,7 +72,7 @@ lark-cli calendar events create --calendar-id primary --data '{
|
||||
lark-cli schema calendar.event.attendees.create
|
||||
## 添加参会人
|
||||
lark-cli calendar event.attendees create \
|
||||
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||
|
||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||
@@ -82,8 +80,7 @@ lark-cli calendar event.attendees create \
|
||||
lark-cli schema calendar.events.delete
|
||||
## 删除空日程
|
||||
lark-cli calendar events delete \
|
||||
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
|
||||
--params '{"need_notification":false}'
|
||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-doc
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。"
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。使用本 skill 时,docs +create、docs +fetch、docs +update 必须携带 --api-version v2;默认使用 DocxXML,也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -32,6 +32,11 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户需要“某个 block 的直达链接 / 锚点链接”时:返回 `文档基础 URL#block_id`。如果当前只有文档 URL 没有 block_id,先用 `docs +fetch --detail with-ids` 拿到目标 block 的 id
|
||||
- 例:
|
||||
- 已知文档 URL = `https://xxx.feishu.cn/docx/doxcn123`
|
||||
- 已知 block_id = `blkcn456`
|
||||
- 应返回 `https://xxx.feishu.cn/docx/doxcn123#blkcn456`
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,重要信息(核心流程、架构、对比、风险、路线图、关键指标、因果关系)优先规划为画板,不要只用文字或表格承载
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
|
||||
@@ -86,4 +86,3 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
|
||||
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
|
||||
@@ -36,10 +36,9 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
| 意图 | `--detail` | 说明 |
|
||||
|------|-----------|------|
|
||||
| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 |
|
||||
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `<p id="blkcnXXXX">`),可用于 `+update` 的 `--block-id` |
|
||||
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `<p id="blkcnXXXX">`),可用于 `+update` 的 `--block-id`,也可用于拼接 `文档URL#block_id` 形式的直达链接 |
|
||||
| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
|
||||
|
||||
|
||||
## 选 `--scope`(读取范围)
|
||||
|
||||
`--scope` 和 `--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
|
||||
|
||||
@@ -4,56 +4,133 @@
|
||||
|
||||
## 两个 Skill 的职责边界
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|------|------|------|
|
||||
| `lark-doc` | 识别画板机会、判断简单/复杂、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容;简单图不需要读取 `lark-whiteboard` |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅复杂图或已有画板更新时由独立 SubAgent 读取 |
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|-------------------|-----------------------------------------------------------|---------------------------------|
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板优先规则
|
||||
|
||||
写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。
|
||||
|
||||
同一篇文档可以有多个画板。优先多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
同一篇文档可以有多个画板。优先设计多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
### 步骤 1:识别画板机会
|
||||
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入简单新画板 | 走步骤 2A |
|
||||
| 文档中需要插入复杂新画板 | 走步骤 2B |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
| 场景 | 入口 |
|
||||
|-------------------------|-----------------------------------------------------------|
|
||||
| 文档中需要思维导图、时序图、类图、饼图、甘特图 | 步骤 2A:使用 mermaid 插入图表 |
|
||||
| 文档中需要插入其他图表/自定义图形 | 步骤 2B: 使用 SVG 插入图表 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
简单图判定:节点少、静态、布局可控、适合一个完整自包含 SVG 表达,例如小型流程、2-3 方对比、小型状态机、简单时间线或小型示意图。
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **分别对每个图表进行决策**
|
||||
|
||||
复杂图判定:节点多、跨泳道/跨系统、需要自动布局或精细排版、包含数据图表、组织架构、复杂架构、复杂依赖、已有画板更新,或需要 `lark-whiteboard` 的渲染验证。
|
||||
如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B
|
||||
中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动
|
||||
SubAgent 插入 SVG。
|
||||
|
||||
### 步骤 2A:简单图 — SubAgent 直接插入 SVG 画板
|
||||
建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表
|
||||
|
||||
### 步骤 2A: 使用 mermaid 插入图表
|
||||
|
||||
```xml
|
||||
|
||||
<whiteboard type="mermaid">
|
||||
mermaid 代码...
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入:
|
||||
|
||||
```xml
|
||||
<whiteboard type="svg"><svg ...>...</svg></whiteboard>
|
||||
|
||||
<whiteboard type="svg">
|
||||
<svg...>...
|
||||
</svg>
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
简单图 SubAgent 的最小上下文:
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
- 图表目标、受众、源段落或数据
|
||||
- 要求读取 `lark-doc-xml.md`;不需要读取 `lark-whiteboard`
|
||||
- SVG 必须完整自包含:包含 `<svg>` 根节点和 `viewBox`,不引用外部图片、脚本、远程资源
|
||||
|
||||
### 步骤 2B:复杂图 — 先创建空白画板
|
||||
#### 画板 SVG 设计指南
|
||||
|
||||
- 主 Agent 使用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 `<whiteboard type="blank"></whiteboard>`。
|
||||
- 从 v2 响应的 `data.document.new_blocks[]` 中读取 `block_type == "whiteboard"` 的 `block_token` 作为 board_token。
|
||||
使用 SVG 插入画板时,最终交付是**画板跨越重排渲染的节点**(你写 SVG → 画板解析)
|
||||
**核心心智纠正 (重要)**:
|
||||
|
||||
### 步骤 3B:复杂图或已有画板 — 启动 lark-whiteboard SubAgent
|
||||
- 大多数 AI 如果只考虑“绝对不报错/完美映射”, 最终给出的都是全篇纯白底色加单层 `<rect>` 的方正卡片网格, 极其死板单调, *
|
||||
*这将被视为不及格!**
|
||||
- **SVG 给你了完全的设计自由**, 请大胆使用你脑内的图标路径 (`<path>`), 连接指引 (`流畅的 <path>`), 各种环境氛围点缀,
|
||||
大胆一点, 充分信任你的品味, 发挥出你的顶级艺术创造力!
|
||||
|
||||
##### SVG 设计 Workflow
|
||||
|
||||
###### 1. 想清楚要画什么
|
||||
|
||||
- **核心信息是什么?** 能做到一图胜千言, 绝对不要只生成平平无奇的文字表格, 要有设计感
|
||||
- **内容充实度**:如果用户描述稀疏简略, 利用你的领域知识扩展, 保证信息维度和内容充实, 但不要过度堆砌, 淹没重点
|
||||
- **视觉层级与隐喻**:这个没有固定的形式, 你自由判断, 比如: 给重要的节点加光环, 加高亮背景;给对比项设计天平或对称结构
|
||||
|
||||
###### 2. 写 SVG
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 布局, 配色, 信息密度, 装饰物——**全部由你判断**, 打破单调的 `<rect>` 牢笼, 严禁通篇用矩形和文字应付用户
|
||||
> 操作边界约束:
|
||||
|
||||
- **语言跟随用户**:图表文字的语言与用户 prompt 保持一致, 技术术语用行业里通用的写法, 不机械翻译
|
||||
- 文字用 `<text>`(不是 `<path>`), 容器宽度留够——画板按 CJK ≈ 1em / Latin ≈ 0.6em 重排
|
||||
- 连线使用正交折线替代斜直线(`<polyline>` 带水平/垂直折点)视觉效果更好
|
||||
- 可自由使用 `translate`, `rotate`, `scale`但请尽量避免使用 `skewX` / `skewY` / `matrix(...)` 发生空间级扭曲
|
||||
|
||||
###### 画板怎么处理 SVG
|
||||
|
||||
画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示);但
|
||||
`<radialGradient>` / `<filter>` / `<clipPath>` 等装饰特性画板完全不支持,会导致渲染问题(见下方⚠️)
|
||||
**不需要所有元素都可编辑, 但必须避免使用不支持的装饰特性, 且要兼顾可编辑和美观漂亮**
|
||||
|
||||
**可识别的元素**
|
||||
|
||||
- 形状:`<rect>` / `<circle>` / `<ellipse>` / `<polygon>`
|
||||
- 连线:`<line>` / `<polyline>` / `<path>`(自动识别为直线 / 折线 / 曲线)
|
||||
- 文本:`<text>` / `<tspan>` 画板硬编码 Noto Sans SC **文字必须用 `<text>`**
|
||||
- 分组:`<g>` / `<a>` / `<use>` 引用 `<symbol>`
|
||||
- 变换:`translate` / `rotate` / `scale` 正常;`skewX` / `skewY` / `matrix(...)` 降级
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ ** 不支持的装饰特性**
|
||||
|
||||
- `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>` → 画板都不支持,**请避免使用,否则会导致画板渲染问题
|
||||
**
|
||||
|
||||
###### 3.插入后审查
|
||||
|
||||
插入画板后,可以从返回值使用 lark-cli 指令,将画板内容导出为 png
|
||||
图片。若是对设计不满意,可以修改后,删除原来的画板再重新插入,或是调用 [
|
||||
`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as image \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
### 步骤 3B:编辑已有画板 — 启动 lark-whiteboard SubAgent
|
||||
|
||||
复杂图和已有画板更新必须启动 SubAgent。主 Agent 只传最小上下文,不直接执行 `lark-whiteboard` 的渲染和写入流程。
|
||||
|
||||
复杂图 SubAgent 的最小上下文:
|
||||
|
||||
- board_token
|
||||
- 图表目标、推荐画板类型、受众
|
||||
- 与图表直接相关的源段落或数据
|
||||
@@ -63,35 +140,12 @@
|
||||
|
||||
### 步骤 4:完成校验
|
||||
|
||||
- 简单 SVG:确认插入的是 `<whiteboard type="svg">`,且内容是完整 `<svg ...>...</svg>`
|
||||
- 复杂画板:确认每个 token 对应的画板都已填充真实内容
|
||||
- Mermaid: 确认插入的是 `<whiteboard type="mermaid">`,且内容 mermaid 语法完整
|
||||
- SVG: 确认插入的是 `<whiteboard type="svg">`,且内容是完整 `<svg ...>...</svg>`
|
||||
- 不保留空白占位画板;复杂路径只有空白画板而无内容视为任务未完成
|
||||
|
||||
---
|
||||
|
||||
## 语义与画板类型映射
|
||||
|
||||
下表用于帮助主 Agent 判断简单/复杂路径,并给 SubAgent 指定推荐画板类型。
|
||||
|
||||
| 语义 | 画板类型 |
|
||||
|------|------|
|
||||
| 小型流程/状态机/简单时间线/小型对比/小型示意图 | SVG 画板(简单路径) |
|
||||
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图(复杂路径) |
|
||||
| 流程/审批/部署/业务流转/状态机 | 流程图(按复杂度分流) |
|
||||
| 跨角色流程/跨系统交互/端到端链路 | 泳道图(复杂路径) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 |
|
||||
| 方案对比/技术选型/功能矩阵 | 对比图 |
|
||||
| 循环/飞轮/闭环/增长链路 | 飞轮图 |
|
||||
| 层级占比/能力模型/需求层次 | 金字塔图 |
|
||||
| 矩形树图/层级面积占比 | 树状图 |
|
||||
| 转化漏斗/销售漏斗 | 漏斗图 |
|
||||
| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid |
|
||||
| 数据分布/占比/饼图 | Mermaid |
|
||||
| 简单自定义图形/小型 SVG 示意图 | SVG 画板(简单路径) |
|
||||
| 柱状图/条形图/数据对比 | 柱状图 |
|
||||
| 折线图/趋势图/时序数据 | 折线图 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、简单/复杂路径和用于画图的源内容
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 第四波 — 画板与润色(并行 Agent)
|
||||
|
||||
8. **优先处理第三波识别出的画板需求**:
|
||||
- 简单图:启动 SVG SubAgent,直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
|
||||
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
|
||||
- 主要章节间补充 `<hr/>`
|
||||
@@ -51,6 +51,8 @@
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
@@ -12,21 +12,20 @@
|
||||
|
||||
## 二、元素选择指南
|
||||
|
||||
涉及图表需求时,先判定简单/复杂:简单图启动 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**;复杂图才使用空白画板 + **lark-whiteboard** SubAgent。
|
||||
涉及图表需求时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|--------------------------------------------|---------------------------------------|
|
||||
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
|
||||
| 重要方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏;SVG SubAgent |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 /思维导图等 | 画板图表 |
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|-|---------------------------------------------------------------|
|
||||
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
|
||||
| 重要方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏;简单 SVG SubAgent;复杂矩阵用 lark-whiteboard SubAgent |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 简单流程图 / 小型状态机 / 小型时间线 | 简单 SVG SubAgent |
|
||||
| 简单自定义图形 / 小型 SVG 示意图 | 简单 SVG SubAgent |
|
||||
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | 空白画板 + lark-whiteboard SubAgent |
|
||||
|
||||
### 画板意图识别
|
||||
|
||||
@@ -49,28 +48,8 @@
|
||||
|
||||
**判断规则:**
|
||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
||||
- 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**
|
||||
- 复杂图或已有画板更新才先插入 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 使用 **lark-whiteboard** skill 写入内容
|
||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
||||
|
||||
### 画板语法与插入
|
||||
|
||||
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需启动 SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
|
||||
|
||||
#### 简单 SVG 画板(SubAgent 插入)
|
||||
|
||||
1. 主 Agent 启动 SubAgent,传入 doc token、插入位置、图表目标和源内容
|
||||
2. SubAgent 使用 `<whiteboard type="svg">完整自包含 SVG</whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入
|
||||
3. SVG 必须包含 `<svg>` 根节点和 `viewBox`,不要引用外部图片、脚本或远程资源
|
||||
|
||||
#### 复杂画板(空白画板 + lark-whiteboard SubAgent)
|
||||
|
||||
1. 用 `<whiteboard type="blank"></whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入空白画板
|
||||
2. 从 v2 响应 `data.document.new_blocks` 中提取画板 `block_token`
|
||||
3. 必须启动 SubAgent,把 `block_token`、图表目标、推荐画板类型和源内容交给它
|
||||
4. SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 并写入该画板;主 Agent 不直接调用画板渲染流程
|
||||
|
||||
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
|
||||
|
||||
## 三、颜色语义
|
||||
|
||||
|
||||
@@ -25,14 +25,13 @@
|
||||
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
2. 系统性评估:结构清晰度、富 block 密度(≥40%)、元素多样性(≥3种)、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、简单/复杂路径和源内容片段
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 第二波 — 定向改写(并行 Agent)
|
||||
|
||||
5. **优先处理第一波识别出的画板候选段落**:
|
||||
- 简单图:启动 SVG SubAgent,直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
|
||||
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`)
|
||||
- 开头适当添加 `<callout>`、重组引言
|
||||
- 纯文本转为 `<grid>`/`<table>`/`<callout>`
|
||||
@@ -47,8 +46,10 @@
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片时使用。涉及云空间/知识库/文档库的知识资产盘点、整理、治理,也从本 skill 进入。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。"
|
||||
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。当用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是资源类型、URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -18,8 +18,7 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**搜文档 / 知识库 / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**盘点/整理/治理云空间、知识库或文档库资产**,读取 [`lark-drive-knowledge-overview.md`](references/lark-drive-knowledge-overview.md) 做范围和 recipe 路由。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`,实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||
@@ -28,19 +27,9 @@ metadata:
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
|
||||
## 知识资产整理 Workflow
|
||||
|
||||
触发上面的知识资产场景后,按以下顺序加载 reference;不要把所有跨域细节塞进当前上下文:
|
||||
|
||||
1. 先阅读 [`lark-drive-knowledge-overview.md`](references/lark-drive-knowledge-overview.md) 判断目标范围和 recipe。
|
||||
2. 需要标准中间产物时阅读 [`lark-drive-knowledge-artifacts.md`](references/lark-drive-knowledge-artifacts.md)。
|
||||
3. 涉及任何写操作或权限治理时阅读 [`lark-drive-knowledge-safety.md`](references/lark-drive-knowledge-safety.md)。
|
||||
4. 涉及知识库节点树、知识库成员、文档库 / my_library 时,切到 [`lark-wiki`](../lark-wiki/SKILL.md) 读取具体命令规则。
|
||||
5. 涉及文档正文读取或报告创建时,切到 [`lark-doc`](../lark-doc/SKILL.md);涉及 Sheets 台账时,切到 [`lark-sheets`](../lark-sheets/SKILL.md)。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
@@ -294,6 +283,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | List secure labels available to the current user |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | Update a Drive file/document secure label; downgrade approval errors require opening the document UI |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# 知识资产整理 Artifact 协议
|
||||
|
||||
> 用途:让 inventory、organize、permission-audit 等 workflow 可串联、可恢复、可评测。字段保持最小稳定;缺失信息用空值、`warnings` 或 `unsupported_checks` 表达,不要编造。
|
||||
|
||||
## 目录约定
|
||||
|
||||
```text
|
||||
./lark-drive-knowledge/<run-id>/
|
||||
scope.json
|
||||
inventory.json
|
||||
organize-plan.json
|
||||
permission-audit.json
|
||||
execution-log.json
|
||||
report.md
|
||||
```
|
||||
|
||||
`run-id` 建议使用 `YYYYMMDD-HHMMSS-<short-scope>`,例如 `20260526-143000-wiki-space`.
|
||||
|
||||
## scope.json
|
||||
|
||||
记录用户目标和本次实际处理范围。
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "",
|
||||
"requested_by_user": "",
|
||||
"scope_type": "drive_folder|wiki_space|wiki_node|my_library|mixed",
|
||||
"root": {
|
||||
"url": "",
|
||||
"space_id": "",
|
||||
"folder_token": "",
|
||||
"node_token": ""
|
||||
},
|
||||
"limits": {
|
||||
"max_depth": -1,
|
||||
"page_limit": 0,
|
||||
"content_read": "none|outline|targeted"
|
||||
},
|
||||
"generated_at": ""
|
||||
}
|
||||
```
|
||||
|
||||
## inventory.json
|
||||
|
||||
记录事实清单。所有后续分析优先消费它。
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "",
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"by_source": {},
|
||||
"by_type": {},
|
||||
"warnings_count": 0
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"source": "drive|wiki|my_library",
|
||||
"title": "",
|
||||
"path": "",
|
||||
"url": "",
|
||||
"token": "",
|
||||
"type": "folder|docx|doc|sheet|bitable|file|slides|mindnote|wiki|shortcut",
|
||||
"space_id": "",
|
||||
"node_token": "",
|
||||
"obj_token": "",
|
||||
"obj_type": "",
|
||||
"folder_token": "",
|
||||
"parent_token": "",
|
||||
"depth": 0,
|
||||
"has_child": false,
|
||||
"owner": "",
|
||||
"created_time": "",
|
||||
"modified_time": "",
|
||||
"evidence": []
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"generated_at": ""
|
||||
}
|
||||
```
|
||||
|
||||
## organize-plan.json
|
||||
|
||||
只表达计划,不代表已经执行。
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "",
|
||||
"mode": "plan",
|
||||
"summary": {
|
||||
"actions_count": 0,
|
||||
"requires_confirmation_count": 0
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "act-001",
|
||||
"action": "create_drive_folder|create_wiki_node|move_drive|move_wiki_node|create_drive_shortcut|create_wiki_shortcut",
|
||||
"source": {},
|
||||
"target": {},
|
||||
"reason": "",
|
||||
"evidence": [],
|
||||
"risk": "read|write|high-risk-write",
|
||||
"requires_confirmation": true,
|
||||
"dry_run_command": "",
|
||||
"execute_command": ""
|
||||
}
|
||||
],
|
||||
"blocked": [],
|
||||
"warnings": [],
|
||||
"generated_at": ""
|
||||
}
|
||||
```
|
||||
|
||||
第一版不生成 delete、overwrite、permission patch、owner transfer 动作。
|
||||
|
||||
## permission-audit.json
|
||||
|
||||
记录权限审计事实、推断和能力边界。
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "",
|
||||
"summary": {
|
||||
"audited_items": 0,
|
||||
"risk_findings": 0,
|
||||
"unsupported_checks": []
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"title": "",
|
||||
"path": "",
|
||||
"url": "",
|
||||
"token": "",
|
||||
"type": "",
|
||||
"wiki_space_members": [],
|
||||
"public_permission": {},
|
||||
"risk_findings": [
|
||||
{
|
||||
"type": "",
|
||||
"severity": "low|medium|high",
|
||||
"fact": "",
|
||||
"inference": "",
|
||||
"suggestion": "",
|
||||
"evidence": [],
|
||||
"requires_confirmation": true
|
||||
}
|
||||
],
|
||||
"unsupported_checks": []
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"generated_at": ""
|
||||
}
|
||||
```
|
||||
|
||||
## execution-log.json
|
||||
|
||||
仅在用户明确确认执行 organize plan 后生成。
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "",
|
||||
"plan_file": "",
|
||||
"results": [
|
||||
{
|
||||
"action_id": "act-001",
|
||||
"status": "success|failed|skipped",
|
||||
"command": "",
|
||||
"result": {},
|
||||
"error": ""
|
||||
}
|
||||
],
|
||||
"generated_at": ""
|
||||
}
|
||||
```
|
||||
@@ -1,115 +0,0 @@
|
||||
# 知识资产盘点 Workflow
|
||||
|
||||
> 前置条件:先读 [`lark-drive-knowledge-overview.md`](lark-drive-knowledge-overview.md) 和 [`lark-drive-knowledge-artifacts.md`](lark-drive-knowledge-artifacts.md)。涉及 Wiki 或文档库时再读 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)。
|
||||
|
||||
## 目标
|
||||
|
||||
对云空间文件夹、知识库、知识库子树或文档库做结构化盘点,生成 `inventory.json`,作为整理、权限审计和报告的事实底座。
|
||||
|
||||
## Step 1: 归一化范围
|
||||
|
||||
- 云空间文件夹 URL:提取 `folder_token`,或用 `drive +inspect` 确认类型。
|
||||
- 知识库 URL:用 `wiki +node-get` 或 `drive +inspect` 获取 `space_id`、`node_token`、`obj_type`、`obj_token`。
|
||||
- `my_library` / 文档库:固定走 `wiki +node-list --space-id my_library --as user`。
|
||||
|
||||
记录到 `scope.json`。
|
||||
|
||||
## Step 2: 盘点云空间文件夹
|
||||
|
||||
先查看 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.files.list --format json
|
||||
```
|
||||
|
||||
读取直接子项:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200}' \
|
||||
--page-all --page-limit 0 --format json --as user
|
||||
```
|
||||
|
||||
对返回的 `type=folder` 子项递归调用同一命令。记录字段:
|
||||
|
||||
- `name` -> `title`
|
||||
- `token`
|
||||
- `type`
|
||||
- `url`
|
||||
- `parent_token`
|
||||
- `owner_id`
|
||||
- `created_time`
|
||||
- `modified_time`
|
||||
|
||||
如需按关键词或时间补充范围,可用 `drive +search`,但目录树以 `drive files list` 为准。
|
||||
|
||||
## Step 3: 盘点知识库或文档库
|
||||
|
||||
读取根层:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-list \
|
||||
--space-id "<space_id_or_my_library>" \
|
||||
--page-all --page-limit 0 --format json --as user
|
||||
```
|
||||
|
||||
对 `has_child=true` 的节点递归:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-list \
|
||||
--space-id "<space_id_or_my_library>" \
|
||||
--parent-node-token "<node_token>" \
|
||||
--page-all --page-limit 0 --format json --as user
|
||||
```
|
||||
|
||||
必要时补节点详情:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-get --node-token "<node_token>" --format json --as user
|
||||
```
|
||||
|
||||
记录字段:
|
||||
|
||||
- `title`
|
||||
- `node_token`
|
||||
- `obj_token`
|
||||
- `obj_type`
|
||||
- `node_type`
|
||||
- `parent_node_token`
|
||||
- `has_child`
|
||||
- `space_id`
|
||||
- `owner`(如果详情返回)
|
||||
|
||||
## Step 4: 可选读取内容结构
|
||||
|
||||
只有用户需要“按内容归类”“识别主题”“生成导航页”时才读取正文结构。优先读 outline,不全量读正文:
|
||||
|
||||
```bash
|
||||
lark-cli docs +fetch \
|
||||
--api-version v2 \
|
||||
--doc "<url_or_token>" \
|
||||
--scope outline \
|
||||
--max-depth 3 \
|
||||
--doc-format markdown \
|
||||
--format json --as user
|
||||
```
|
||||
|
||||
失败时保留结构盘点结果,并在 `warnings` 中标记 `content_outline_failed`。
|
||||
|
||||
## Step 5: 输出 inventory.json
|
||||
|
||||
把所有条目写入 `./lark-drive-knowledge/<run-id>/inventory.json`。聊天回复只输出摘要:
|
||||
|
||||
- 总数
|
||||
- 按 source/type 统计
|
||||
- 空标题数量
|
||||
- 重复标题候选数量
|
||||
- 未读取或失败的节点数量
|
||||
- artifact 路径
|
||||
|
||||
## 停止条件
|
||||
|
||||
- 没有权限读取根节点或根文件夹:停止并给出授权建议。
|
||||
- 分页失败或 cursor 不前进:保留已读结果,写入 `warnings`。
|
||||
- 结果规模过大:停止深挖正文,只输出结构清单并提示用户缩小范围。
|
||||
- 知识库和云空间混合范围不清楚:先让用户确认是否都处理。
|
||||
@@ -1,107 +0,0 @@
|
||||
# 散乱知识整理 Workflow
|
||||
|
||||
> 前置条件:先有 `inventory.json`,并阅读 [`lark-drive-knowledge-safety.md`](lark-drive-knowledge-safety.md)。第一版默认只生成整理计划,不直接执行。
|
||||
|
||||
## 目标
|
||||
|
||||
帮助用户把云空间文件夹、知识库或文档库下的散乱文档组织成更清晰的目录结构。输出 `organize-plan.json`。
|
||||
|
||||
## 输入
|
||||
|
||||
- 必需:`inventory.json`
|
||||
- 可选:用户给出的目标分类规则,例如“按项目”“按业务线”“按系统”“按年份”“按文档类型”
|
||||
- 可选:outline 读取结果,用于按内容主题分类
|
||||
|
||||
## 分析规则
|
||||
|
||||
优先基于事实字段:
|
||||
|
||||
- 路径层级过深或过平。
|
||||
- 空标题、重复标题、相似标题。
|
||||
- 同一主题散落在多个目录。
|
||||
- 云空间文件夹中在线文档和普通文件混放。
|
||||
- 知识库 shortcut 复用或源文档散落。
|
||||
- 文档标题包含“旧版”“废弃”“草稿”“临时”等治理信号。
|
||||
|
||||
模型推断必须写入 `reason` 和 `evidence`,不能把推断当事实。
|
||||
|
||||
## 生成计划
|
||||
|
||||
允许生成的 action:
|
||||
|
||||
| action | 说明 |
|
||||
|-|-|
|
||||
| `create_drive_folder` | 在 Drive 中创建目标文件夹 |
|
||||
| `create_wiki_node` | 在知识库或文档库 / `my_library` 中创建目录节点 |
|
||||
| `move_drive` | 移动 Drive 文件/文件夹 |
|
||||
| `move_wiki_node` | 移动知识库节点 |
|
||||
| `create_drive_shortcut` | 在 Drive 目标文件夹创建快捷方式 |
|
||||
| `create_wiki_shortcut` | 在知识库中创建 shortcut 节点 |
|
||||
|
||||
禁止生成的 action:
|
||||
|
||||
- delete
|
||||
- overwrite
|
||||
- permission patch
|
||||
- member remove
|
||||
- owner transfer
|
||||
|
||||
每个 action 必须包含:
|
||||
|
||||
- source
|
||||
- target
|
||||
- reason
|
||||
- evidence
|
||||
- `requires_confirmation=true`
|
||||
- dry-run command
|
||||
- execute command
|
||||
|
||||
## 命令模板
|
||||
|
||||
Drive 创建文件夹:
|
||||
|
||||
```bash
|
||||
lark-cli drive +create-folder --name "<folder_name>" --folder-token "<parent_folder_token>" --dry-run --as user
|
||||
```
|
||||
|
||||
Drive 移动:
|
||||
|
||||
```bash
|
||||
lark-cli drive +move --file-token "<token>" --type "<type>" --folder-token "<target_folder_token>" --dry-run --as user
|
||||
```
|
||||
|
||||
Drive 快捷方式:
|
||||
|
||||
```bash
|
||||
lark-cli drive +create-shortcut --file-token "<token>" --type "<type>" --folder-token "<target_folder_token>" --dry-run --as user
|
||||
```
|
||||
|
||||
Wiki 创建节点:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-create --space-id "<space_id_or_my_library>" --parent-node-token "<parent_node_token>" --title "<title>" --obj-type docx --dry-run --as user
|
||||
```
|
||||
|
||||
Wiki 移动节点:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +move --node-token "<node_token>" --target-parent-token "<target_parent_node_token>" --dry-run --as user
|
||||
```
|
||||
|
||||
Wiki shortcut:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-create --space-id "<space_id>" --parent-node-token "<parent_node_token>" --node-type shortcut --origin-node-token "<source_node_token>" --title "<title>" --dry-run --as user
|
||||
```
|
||||
|
||||
## 执行与验收
|
||||
|
||||
只有用户明确确认某个 `organize-plan.json` 后,才逐条执行。执行后写 `execution-log.json`,并重新跑 inventory 验证目标目录结构。
|
||||
|
||||
聊天回复要给出:
|
||||
|
||||
- 计划文件路径
|
||||
- action 数量
|
||||
- 需要确认的高影响动作
|
||||
- 被阻塞的动作和原因
|
||||
- 下一步确认方式
|
||||
@@ -1,59 +0,0 @@
|
||||
# 知识资产整理 Workflow 总览
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 盘点某个云空间文件夹、知识库、知识库节点或文档库下的文档和目录。
|
||||
- 整理散乱文档:归类、生成目录结构、移动计划、快捷方式计划、导航页建议。
|
||||
- 治理知识资产:重复、空标题、孤立、未归档、命名混乱、内容缺失或过期。
|
||||
- 审计权限风险:Wiki 空间成员、文档公开链接、组织外访问、分享/复制/下载设置。
|
||||
|
||||
## 目标范围模型
|
||||
|
||||
| 用户目标 | 视作 | 主入口 |
|
||||
|-|-|-|
|
||||
| 云空间文件夹 | Drive folder tree | `drive files list` / `drive +search` |
|
||||
| 知识库 / 知识库节点 | Wiki space/node tree | `wiki +node-list` / `wiki +node-get` |
|
||||
| 文档库 / my_library | Wiki personal library | `wiki +node-list --space-id my_library --as user` |
|
||||
| 文档正文或标题结构 | Docs content | `docs +fetch --api-version v2` |
|
||||
| 报告或台账 | Docs / Sheets | `docs +create` / `sheets +create` |
|
||||
|
||||
不要把“文档库”当成 Drive 根目录;它应走 Wiki personal library。
|
||||
|
||||
## Recipe 路由
|
||||
|
||||
| 用户意图 | 读取文件 | 产物 |
|
||||
|-|-|-|
|
||||
| 盘点、梳理、导出清单、看目录结构 | [`lark-drive-knowledge-inventory.md`](lark-drive-knowledge-inventory.md) | `inventory.json` |
|
||||
| 整理、归类、组织目录、生成移动计划 | [`lark-drive-knowledge-organize.md`](lark-drive-knowledge-organize.md) | `organize-plan.json` |
|
||||
| 权限风险、公开链接、组织外访问、成员过多 | [`lark-drive-knowledge-permission-audit.md`](lark-drive-knowledge-permission-audit.md) | `permission-audit.json` |
|
||||
|
||||
复合意图按链路执行:
|
||||
|
||||
```text
|
||||
盘点并整理 -> inventory -> organize
|
||||
盘点并审计权限 -> inventory -> permission-audit
|
||||
整理并输出报告 -> inventory -> organize -> report artifact
|
||||
```
|
||||
|
||||
## 执行原则
|
||||
|
||||
- 大范围结果默认写入本地 artifact,不把完整清单塞进聊天。
|
||||
- 每次 run 使用独立目录:`./lark-drive-knowledge/<run-id>/`。
|
||||
- 工作流之间通过 artifact 串联,优先复用已有 `inventory.json`,不要无故重复爬取。
|
||||
- 所有判断必须区分事实、推断、建议;没有证据的内容写入 `warnings` 或 `unsupported_checks`。
|
||||
- 原生 API 调用前必须先运行 `lark-cli schema <service>.<resource>.<method>` 校验参数结构。
|
||||
- 写操作和权限治理必须遵守 [`lark-drive-knowledge-safety.md`](lark-drive-knowledge-safety.md)。
|
||||
|
||||
## 当前能力边界
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|-|-|-|
|
||||
| 知识库节点盘点 | 支持 | 递归 `wiki +node-list` |
|
||||
| 云空间文件夹盘点 | 支持 | 递归 `drive files list` |
|
||||
| 文档标题结构读取 | 支持 | `docs +fetch --scope outline` |
|
||||
| Wiki 空间成员审计 | 支持 | `wiki +member-list` |
|
||||
| 文档公开权限审计 | 支持 | `drive permission.public get` |
|
||||
| 单文档协作者全量枚举 | 暂不支持 | 当前 Drive permission members 只有 `auth/create/transfer_owner` |
|
||||
| 自动删除、覆盖、降权、改权限 | 第一版不执行 | 只输出计划和建议 |
|
||||
@@ -1,109 +0,0 @@
|
||||
# 权限风险审计 Workflow
|
||||
|
||||
> 前置条件:先读 [`lark-drive-knowledge-overview.md`](lark-drive-knowledge-overview.md)、[`lark-drive-knowledge-artifacts.md`](lark-drive-knowledge-artifacts.md) 和 [`lark-drive-knowledge-safety.md`](lark-drive-knowledge-safety.md)。第一版只审计和建议,不自动改权限。
|
||||
|
||||
## 目标
|
||||
|
||||
审计 云空间 / 知识库 / 文档库范围内的权限风险,重点覆盖:
|
||||
|
||||
- Wiki 空间成员和角色。
|
||||
- 文档公开权限、外部访问、链接分享、复制/下载/打印限制。
|
||||
- 当前能力无法验证的权限项。
|
||||
|
||||
## 当前能力边界
|
||||
|
||||
| 检查项 | 状态 | 命令 |
|
||||
|-|-|-|
|
||||
| Wiki 空间成员 | 支持 | `wiki +member-list` |
|
||||
| 文档公开权限 | 支持 | `drive permission.public get`,仅限 schema 支持的文档类型 |
|
||||
| Drive 文件夹公开权限 | 暂不支持 | `drive.permission.public.get` 不支持 `type=folder` |
|
||||
| 当前用户/应用是否具备某权限 | 支持 | `drive permission.members auth` |
|
||||
| 单文档显式协作者全量枚举 | 暂不支持 | 当前无 `drive.permission.members.list` |
|
||||
| 自动关闭公开权限或降权 | 第一版不执行 | 只输出建议 |
|
||||
|
||||
## Step 1: 输入范围
|
||||
|
||||
优先消费 `inventory.json`。如果用户只给一个 URL,先按 [`lark-drive-knowledge-inventory.md`](lark-drive-knowledge-inventory.md) 做最小盘点。
|
||||
|
||||
## Step 2: Wiki 空间成员审计
|
||||
|
||||
涉及知识库空间或文档库 / `my_library` 时:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +member-list \
|
||||
--space-id "<space_id_or_my_library>" \
|
||||
--page-all --page-limit 0 --format json --as user
|
||||
```
|
||||
|
||||
审计信号:
|
||||
|
||||
- admin 数量异常多。
|
||||
- 成员包含部门、群或开放范围较大的 member_type。
|
||||
- 文档库成员结果缺失或不适用时写入 `warnings`,不要推断。
|
||||
|
||||
## Step 3: 文档公开权限审计
|
||||
|
||||
先查看 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.public.get --format json
|
||||
```
|
||||
|
||||
只对 inventory 中 `type` 属于以下集合的条目查询:
|
||||
|
||||
```text
|
||||
doc, docx, sheet, bitable, file, wiki, mindnote, minutes, slides
|
||||
```
|
||||
|
||||
`type=folder` 的 Drive 文件夹不要调用 `drive.permission.public get`。将该 item 写入 `unsupported_checks` 或 `warnings`,例如 `drive_folder_public_permission_unsupported`,并继续审计其他条目。
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--format json --as user
|
||||
```
|
||||
|
||||
Token/type 选择:
|
||||
|
||||
- 云空间文件:用 Drive `token` 和 `type=file`。
|
||||
- 云空间文件夹:不执行 `permission.public.get`,记录为未覆盖检查。
|
||||
- 知识库节点权限:优先用 `node_token` 和 `type=wiki`。
|
||||
- 底层文档权限:用 `obj_token` 和 `obj_type`。
|
||||
|
||||
如果某类 token 查询失败,不要中断全局审计;写入该 item 的 `warnings`。如果是 `type=folder`,不要把它当作 API 失败,而是写入 `unsupported_checks`。
|
||||
|
||||
## 风险规则
|
||||
|
||||
| 字段 | 高风险 | 中风险 |
|
||||
|-|-|-|
|
||||
| `link_share_entity` | `anyone_editable`, `anyone_readable` | `tenant_editable` |
|
||||
| `external_access` | `true` 且链接或分享范围较宽 | `true` |
|
||||
| `share_entity` | `anyone` | `same_tenant` |
|
||||
| `security_entity` | `anyone_can_view` | `anyone_can_edit` |
|
||||
| Wiki member role | admin 过多 | 部门/群成员需确认 |
|
||||
|
||||
每条风险都必须写清楚:
|
||||
|
||||
- `fact`:API 返回字段。
|
||||
- `inference`:为什么可能有风险。
|
||||
- `suggestion`:建议 owner 或管理员确认的动作。
|
||||
- `evidence`:token、URL、path、字段名和值。
|
||||
- `requires_confirmation=true`。
|
||||
|
||||
## 输出
|
||||
|
||||
写入 `permission-audit.json`,聊天回复只给摘要:
|
||||
|
||||
- 审计对象数量。
|
||||
- 高/中/低风险数量。
|
||||
- 公开权限风险 Top N。
|
||||
- Wiki 成员风险摘要。
|
||||
- 未覆盖检查:必须包含 `explicit_collaborator_list`,说明当前 CLI 无法枚举单文档显式协作者列表。
|
||||
- 如果 inventory 中包含 `type=folder`,未覆盖检查必须包含 `drive_folder_public_permission`,说明 `drive.permission.public.get` 不支持 Drive 文件夹。
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 不自动调用 `permission.public.patch`。
|
||||
- 不自动调用 `permission.members.create`、`transfer_owner` 或任何成员删除/降权接口。
|
||||
- 不把“无法枚举协作者”说成“没有协作者风险”。
|
||||
- 不把模型推断写成事实。
|
||||
@@ -1,50 +0,0 @@
|
||||
# 知识资产整理安全策略
|
||||
|
||||
> 默认安全级别:只读或计划优先。任何会改变用户文档、目录或权限的操作,都必须先生成计划并让用户明确确认。
|
||||
|
||||
## 动作分级
|
||||
|
||||
| 分级 | 示例 | 策略 |
|
||||
|-|-|-|
|
||||
| Read-only | 列目录、查元数据、读 outline、读公开权限 | 可直接执行 |
|
||||
| Plan-only | 生成整理计划、权限治理建议、报告草稿 | 可直接执行 |
|
||||
| Confirmed write | 创建文件夹、创建知识库节点、移动文档、创建快捷方式、新建报告 | 必须先展示计划,用户确认后执行 |
|
||||
| High-risk write | 删除、覆盖、改权限、转移 owner、移除成员、公开权限 patch | 第一版 workflow 不执行 |
|
||||
| Unsupported | 单文档协作者全量枚举 | 明确说明当前能力无法验证 |
|
||||
|
||||
## 强制规则
|
||||
|
||||
- 盘点、整理建议、权限审计默认不修改任何资源。
|
||||
- `organize-plan.json` 不是执行授权;只有用户明确说执行某个 plan,才进入执行阶段。
|
||||
- 执行前必须展示 action 列表,包括 source、target、reason、risk、dry-run command。
|
||||
- 执行写操作前先跑对应 `--dry-run`;dry-run 通过不等于用户已确认真实执行。
|
||||
- 不自动执行 delete、overwrite、permission patch、member remove、owner transfer。
|
||||
- 权限治理第一版只输出风险和建议,不自动收敛权限。
|
||||
- 对无法确认的风险,写成 `unsupported_checks` 或 `requires_confirmation=true`,不要当作事实。
|
||||
|
||||
## 需要停下来问用户的情况
|
||||
|
||||
- 目标范围不明确,例如同时给出多个 folder/wiki URL 但未说明是否都处理。
|
||||
- 计划包含跨空间移动、跨 Drive/Wiki 移动或大量移动。
|
||||
- 目标目录已有同名节点/文件夹,无法判断是否复用。
|
||||
- 用户要求“直接整理好”,但计划还没有展示和确认。
|
||||
- 用户要求“治理权限”,但动作涉及改公开权限、移除成员、降权或转移 owner。
|
||||
- 节点数量或文件数量超出上下文可处理范围,需要改为文件产物或缩小范围。
|
||||
|
||||
## 权限风险表述
|
||||
|
||||
表述必须区分:
|
||||
|
||||
- 事实:API 返回的字段,例如 `external_access=true`。
|
||||
- 推断:基于规则得到的风险,例如“可能允许组织外传播”。
|
||||
- 建议:下一步动作,例如“建议 owner 人工确认是否关闭外部访问”。
|
||||
- 未覆盖:当前 API/CLI 不能验证的项,例如“无法枚举单文档显式协作者列表”。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
事实:link_share_entity=anyone_editable。
|
||||
推断:互联网获得链接的人可能可编辑,属于高风险公开权限。
|
||||
建议:请 owner 确认是否需要关闭链接分享或收敛为 tenant_readable。
|
||||
未覆盖:未检查显式协作者列表,因为当前 CLI 没有 permission.members.list。
|
||||
```
|
||||
52
skills/lark-drive/references/lark-drive-secure-label.md
Normal file
52
skills/lark-drive/references/lark-drive-secure-label.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# drive +secure-label-list / +secure-label-update(云文档密级标签)
|
||||
|
||||
## 何时使用
|
||||
|
||||
- `drive +secure-label-list`:查询当前用户可用的密级标签,先拿到目标 `id`。
|
||||
- `drive +secure-label-update`:把目标云文档调整为指定密级标签。
|
||||
|
||||
这两个 shortcut 都使用用户身份(`--as user`)。修改密级前,通常先执行 `+secure-label-list` 确认可用标签 ID。
|
||||
|
||||
## 查询可用密级标签
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-list --page-size 10 --lang zh
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--page-size` | 分页大小,范围 `1..10`,默认 `10` |
|
||||
| `--page-token` | 上一页响应里的 `page_token` |
|
||||
| `--lang` | 标签语言:`zh`、`en`、`ja` |
|
||||
|
||||
底层接口:`GET /open-apis/drive/v2/my_secure_labels`。
|
||||
|
||||
## 修改文档密级
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxx" \
|
||||
--label-id "7217780879644737539"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--token` | 目标文档 URL 或 bare token;URL 可自动推断 `--type` |
|
||||
| `--type` | bare token 必填;URL 输入时可省略。可选:`doc`、`docx`、`sheet`、`file`、`bitable`、`mindnote`、`slides` |
|
||||
| `--label-id` | 要设置的密级标签 ID |
|
||||
|
||||
底层接口:`PATCH /open-apis/drive/v2/files/:file_token/secure_label`,query 参数 `type`,请求体 `{ "id": "<label-id>" }`。
|
||||
|
||||
## 错误处理
|
||||
|
||||
CLI 不会在 shortcut 中为密级错误码追加专用 hint;agent 必须根据返回的 `error.code` 做以下引导。
|
||||
|
||||
| 错误码 | 含义 | 引导 |
|
||||
|--------|------|------|
|
||||
| `1063013` | 密级降级需要审批 | 提示用户打开目标文档,在文档界面完成密级降级审批后重试;如果用户传入的是文档 URL,必须把该 URL 一并给用户作为操作入口 |
|
||||
|
||||
遇到 `1063013` 时,不要继续重试 API,也不要提示补 scope;这是文档侧审批流程要求,需要用户到文档里操作。
|
||||
@@ -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 message receive, reactions, chat member changes, 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, VC meeting ended, Minutes generated, 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"]
|
||||
@@ -143,3 +143,5 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
| Topic | Reference | Coverage |
|
||||
|---|---|---|
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 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) |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 1 VC EventKey (`vc.meeting.participant_meeting_ended_v1`) + field reference + time conversion gotchas (unix seconds → local RFC3339) |
|
||||
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + enrichment & degradation semantics (minute detail API fills `title`; `minute_source` from event payload survives enrichment failure) |
|
||||
|
||||
54
skills/lark-event/references/lark-event-minutes.md
Normal file
54
skills/lark-event/references/lark-event-minutes.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Minutes Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (1)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `minutes.minute.generated_v1` | A minute (妙记) has been generated |
|
||||
|
||||
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `minutes.minute.generated_v1` | `minutes:minutes.basic:read` | user |
|
||||
|
||||
Requires `--as user`.
|
||||
|
||||
## `minutes.minute.generated_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `minutes.minute.generated_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `minute_token` | string | Minute token |
|
||||
| `title` | string | Minute title (enriched via detail API) |
|
||||
| `minute_source` | object | Minute source metadata; only present when the source is a meeting |
|
||||
| `minute_source.source_type` | string | Source type; only present when the source is a meeting (value: `meeting`) |
|
||||
| `minute_source.source_entity_id` | string | Source entity ID (meeting ID); only present when the source is a meeting |
|
||||
|
||||
### Enrichment & degradation
|
||||
|
||||
The Process hook calls `GET /open-apis/minutes/v1/minutes/{minute_token}` to enrich `title`. If the detail API fails, this field is left empty — the base fields (`type`, `event_id`, `timestamp`, `minute_token`, `minute_source`) are always present.
|
||||
|
||||
`minute_source` is populated from the event payload directly (not the detail API), so it survives enrichment failures. Note: `minute_source` is only present when the minute originates from a meeting; for other sources (e.g. recording, local upload) this field is absent.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user
|
||||
|
||||
# Project title and token only (skip events where enrichment failed)
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user \
|
||||
--jq 'select(.title != "") | {minute_token, title}'
|
||||
|
||||
# Filter by source type
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user \
|
||||
--jq 'select(.minute_source.source_type == "meeting") | {minute_token, title}'
|
||||
```
|
||||
50
skills/lark-event/references/lark-event-vc.md
Normal file
50
skills/lark-event/references/lark-event-vc.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# VC Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (1)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
|
||||
|
||||
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
|
||||
Requires `--as user`.
|
||||
|
||||
## `vc.meeting.participant_meeting_ended_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `meeting_id` | string | Meeting ID |
|
||||
| `topic` | string | Meeting topic |
|
||||
| `meeting_no` | string | Meeting number |
|
||||
| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone |
|
||||
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone |
|
||||
| `calendar_event_id` | string | Calendar event ID associated with the meeting |
|
||||
|
||||
### 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.
|
||||
- No detail API call is made; all fields come from the event payload itself.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
|
||||
|
||||
# Project meeting topic and end time only
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user \
|
||||
--jq '{meeting: .meeting_id, topic: .topic, ended: .end_time}'
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg`、`whisper` 等本地转写命令。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物;6.更新妙记标题(重命名妙记);7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -98,6 +98,8 @@ Minutes (妙记) ← minute_token 标识
|
||||
> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
|
||||
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
|
||||
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
|
||||
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
@@ -108,10 +110,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`
|
||||
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
|
||||
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
|
||||
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
|
||||
| [`+update`](references/lark-minutes-update.md) | Update a minute's title |
|
||||
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) |
|
||||
|
||||
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
|
||||
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
|
||||
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
|
||||
- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。
|
||||
- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID,不支持姓名)。
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
|
||||
@@ -135,5 +141,7 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
|
||||
| `+search` | `minutes:minutes.search:read` |
|
||||
| `minutes.get` | `minutes:minutes:readonly` |
|
||||
| `+download` | `minutes:minutes.media:export` |
|
||||
| `+update` | `minutes:minutes:update` |
|
||||
| `+speaker-replace` | `minutes:minutes:update` |
|
||||
|
||||
<!-- AUTO-GENERATED-END -->
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# minutes +speaker-replace
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli minutes +speaker-replace`。
|
||||
|
||||
## 典型触发表达
|
||||
|
||||
- "把这条妙记里 A 的发言改成 B"
|
||||
- "妙记说话人识别错了,帮我把张三的部分换成李四"
|
||||
- "妙记说话人修改 / 替换 / 重新归属"
|
||||
- "改一下妙记的说话人"
|
||||
|
||||
## 命令示例
|
||||
|
||||
```bash
|
||||
lark-cli minutes +speaker-replace \
|
||||
--minute-token obcnxxxxxxxxxxxxxxxxxxxx \
|
||||
--from-user-id ou_old_speaker_open_id \
|
||||
--to-user-id ou_new_speaker_open_id
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
|
||||
| `--from-user-id <ou_xxx>` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
|
||||
| `--to-user-id <ou_xxx>` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
|
||||
|
||||
> **重要**:`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID,**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。
|
||||
|
||||
## 认证与权限
|
||||
|
||||
- 所需 scope:`minutes:minutes:update`。
|
||||
|
||||
## 输出结果
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致 |
|
||||
| `from_user_id` | 被替换的原说话人 open_id,与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 |
|
||||
| `to_user_id` | 替换后的新说话人 open_id,与输入的 `--to-user-id` 一致 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
41
skills/lark-minutes/references/lark-minutes-update.md
Normal file
41
skills/lark-minutes/references/lark-minutes-update.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# minutes +update
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
修改飞书妙记的标题(topic)。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli minutes +update`。
|
||||
|
||||
## 典型触发表达
|
||||
|
||||
- "把这个妙记的标题改成 xxx"
|
||||
- "重命名这条妙记"
|
||||
- "修改妙记标题"
|
||||
|
||||
## 命令示例
|
||||
|
||||
```bash
|
||||
lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
|
||||
| `--topic <string>` | 是 | 新的妙记标题 |
|
||||
|
||||
## 认证与权限
|
||||
- 所需 scope:`minutes:minutes:update`。
|
||||
|
||||
## 输出结果
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致,可继续用于查询妙记信息、下载媒体或获取纪要产物 |
|
||||
| `topic` | 修改后的妙记标题,与输入的 `--topic` 一致 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 1.2.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。"
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
|
||||
@@ -123,6 +123,11 @@ lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx"
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
|
||||
|
||||
# 多值筛选:只展示 Grade 为 A 或 B 的行(multiValue 不传 compare-type)
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "C" --filter-type "multiValue" --expected '["A","B"]'
|
||||
```
|
||||
|
||||
参数:
|
||||
@@ -134,7 +139,7 @@ lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx"
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母,如 `E` |
|
||||
| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` |
|
||||
| `--filter-type` | 是 | `multiValue` / `number` / `text` / `color` |
|
||||
| `--compare-type` | 否 | 比较运算符 |
|
||||
| `--expected` | 是 | 筛选值 JSON 数组 |
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。"
|
||||
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
|
||||
@@ -13,18 +13,24 @@ If the user query only specifies a task name (e.g., "Complete task Lobster No. 1
|
||||
List tasks assigned to the current user, with support for filtering by completion status, creation time, and due date.
|
||||
By default, the command will automatically paginate up to 20 times. Use `--page-all` to fetch more (up to 40 pages).
|
||||
|
||||
> **Pending vs all tasks:** When `--complete` is not provided, the result contains **both completed and incomplete tasks**.
|
||||
> For standup / daily-summary / pending-todo scenarios, you **must** pass `--complete=false`; otherwise completed tasks will be surfaced as if they were still pending.
|
||||
|
||||
## Recommended Commands
|
||||
|
||||
```bash
|
||||
# Search for a specific task by name
|
||||
lark-cli task +get-my-tasks --query "Lobster No. 1"
|
||||
|
||||
# Get all my tasks (fetches up to 20 pages by default)
|
||||
# Get all my tasks, both completed and incomplete (fetches up to 20 pages by default)
|
||||
lark-cli task +get-my-tasks
|
||||
|
||||
# Get my incomplete tasks (fetches up to 20 pages by default)
|
||||
# Pending-only: my incomplete tasks (use this for standup/daily-summary)
|
||||
lark-cli task +get-my-tasks --complete=false
|
||||
|
||||
# Pending-only with a due-date upper bound (e.g. end of today / this week)
|
||||
lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00"
|
||||
|
||||
# Fetch all my tasks (up to 40 pages)
|
||||
lark-cli task +get-my-tasks --page-all
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ Meeting (视频会议)
|
||||
├── Transcript (文字记录)
|
||||
├── Summary (总结)
|
||||
├── Todos (待办)
|
||||
└── Chapters (章节)
|
||||
├── Chapters (章节)
|
||||
└── Keywords (推荐关键词)
|
||||
```
|
||||
|
||||
> **注意**:`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。
|
||||
|
||||
@@ -93,6 +93,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|
||||
| `artifacts.summary` | AI 总结(JSON 内联) |
|
||||
| `artifacts.todos` | 待办事项(JSON 内联) |
|
||||
| `artifacts.chapters` | 章节纪要(JSON 内联) |
|
||||
| `artifacts.keywords` | 妙记推荐关键词(JSON 内联) |
|
||||
| `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` |
|
||||
|
||||
## 如何获取输入参数
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-wiki
|
||||
version: 1.0.0
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。知识库 / 文档库的文档和目录盘点、整理、治理 workflow 统一从 lark-drive 进入;本 skill 只处理知识空间、成员和节点操作。"
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -24,7 +24,6 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**盘点、整理、治理知识库 / 文档库中的文档和目录**,不要在本 skill 里展开 workflow;切到 [`lark-drive`](../lark-drive/SKILL.md),按其 `lark-drive-knowledge-overview.md` 入口编排。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL:**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
|
||||
- URL(`.../wiki/<token>`):`lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`。
|
||||
@@ -117,4 +116,3 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
|
||||
| `nodes.move` | `wiki:node:move` |
|
||||
| `nodes.create` | `wiki:node:create` |
|
||||
| `nodes.list` | `wiki:node:retrieve` |
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ lark-cli auth login --domain calendar,task
|
||||
## 工作流
|
||||
|
||||
```
|
||||
{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件)
|
||||
└─► task +get-my-tasks [--due-end] ──► 未完成待办列表
|
||||
{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件)
|
||||
└─► task +get-my-tasks --complete=false [--due-end] ──► 未完成待办列表
|
||||
│
|
||||
▼
|
||||
AI 汇总(时间转换 + 冲突检测 + 排序)──► 摘要
|
||||
@@ -54,19 +54,21 @@ lark-cli calendar +agenda --start "2026-03-26T00:00:00+08:00" --end "2026-03-26T
|
||||
### Step 2: 获取未完成待办
|
||||
|
||||
```bash
|
||||
# 默认:返回分配给当前用户的未完成任务(最多 20 条)
|
||||
lark-cli task +get-my-tasks
|
||||
# 默认 pending 摘要:必须显式过滤未完成任务(最多 20 条)
|
||||
lark-cli task +get-my-tasks --complete=false
|
||||
|
||||
# 只看指定日期前到期的(推荐用于摘要场景,减少数据量)
|
||||
lark-cli task +get-my-tasks --due-end "2026-03-27T23:59:59+08:00"
|
||||
# 只看指定日期前到期的未完成任务(推荐用于摘要场景,减少数据量)
|
||||
lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00"
|
||||
|
||||
# 获取全部(超过 20 条时)
|
||||
lark-cli task +get-my-tasks --page-all
|
||||
# 获取全部未完成任务(超过 20 条时)
|
||||
lark-cli task +get-my-tasks --complete=false --page-all
|
||||
```
|
||||
|
||||
> **注意**:不带过滤条件时可能返回大量历史待办(实测 30+ 条、100KB+),容易超出上下文限制。摘要场景建议:
|
||||
> **注意**:`+get-my-tasks` 不带 `--complete` 时会**同时返回已完成和未完成任务**,会把已完成任务当成"待办"展示进摘要里。站会/日报这种 pending 汇总场景**必须**显式带上 `--complete=false`,不要省略。
|
||||
>
|
||||
> 数据量层面也建议加过滤:
|
||||
> - 用 `--due-end` 过滤出目标日期前到期的任务
|
||||
> - 如果也需要无截止日期的任务,可不加过滤,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办"
|
||||
> - 如果也需要无截止日期的任务,可不加 `--due-end`,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办"
|
||||
|
||||
### Step 3: AI 汇总
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 9
|
||||
- Coverage: 31.0%
|
||||
- Denominator: 31 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 32.3%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
@@ -13,6 +13,7 @@
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
@@ -34,6 +35,8 @@
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
| ✓ | drive +secure-label-list | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--page-size`; `--page-token`; `--lang` | dry-run only; live label availability depends on tenant security-label configuration |
|
||||
| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
|
||||
| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
|
||||
@@ -45,12 +45,30 @@ func TestDriveInspectDryRun_FileURL(t *testing.T) {
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/file/boxcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_FolderURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/drive/folder/fldcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoChatDriveFolderURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://feishu.doubao.com/chat/drive/fldcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/shr/fldcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_MindnoteURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/mindnote/mncnDryRunE2E")
|
||||
|
||||
98
tests/cli_e2e/drive/drive_secure_label_dryrun_test.go
Normal file
98
tests/cli_e2e/drive/drive_secure_label_dryrun_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_SecureLabelDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMethod string
|
||||
wantURL string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "list available labels",
|
||||
args: []string{
|
||||
"drive", "+secure-label-list",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_1",
|
||||
"--lang", "zh",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantURL: "/open-apis/drive/v2/my_secure_labels",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 5 {
|
||||
t.Fatalf("page_size = %d, want 5\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "page_1" {
|
||||
t.Fatalf("page_token = %q, want page_1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.lang").String(); got != "zh" {
|
||||
t.Fatalf("lang = %q, want zh\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update label with URL inference",
|
||||
args: []string{
|
||||
"drive", "+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "PATCH",
|
||||
wantURL: "/open-apis/drive/v2/files/doxcnE2E001/secure_label",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != "docx" {
|
||||
t.Fatalf("type = %q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.id").String(); got != "7217780879644737539" {
|
||||
t.Fatalf("body.id = %q, want label id\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_token").String(); got != "doxcnE2E001" {
|
||||
t.Fatalf("file_token = %q, want doxcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
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",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != tt.wantMethod {
|
||||
t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
tt.assert(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user