diff --git a/cmd/notice_test.go b/cmd/notice_test.go new file mode 100644 index 000000000..53f7e6acb --- /dev/null +++ b/cmd/notice_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/deprecation" +) + +// composePendingNotice must surface a deprecated-command alias under the +// "deprecated_command" key, with the migration target and a skill-update hint, +// so the JSON "_notice" envelope reaches users who run pre-refactor commands +// without ever reading --help. +func TestComposePendingNoticeDeprecatedCommand(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + + deprecation.SetPending(&deprecation.Notice{ + Command: "+read", + Replacement: "+cells-get", + Skill: "lark-sheets", + }) + + got := composePendingNotice() + if got == nil { + t.Fatal("composePendingNotice() = nil, want deprecated_command entry") + } + entry, ok := got["deprecated_command"].(map[string]interface{}) + if !ok { + t.Fatalf("missing deprecated_command key: %#v", got) + } + if entry["command"] != "+read" { + t.Errorf("command = %v, want +read", entry["command"]) + } + if entry["replacement"] != "+cells-get" { + t.Errorf("replacement = %v, want +cells-get", entry["replacement"]) + } + if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") { + t.Errorf("message missing skill-update hint: %q", msg) + } +} + +// With nothing pending, the provider returns nil so no "_notice" field is +// emitted on a clean run. +func TestComposePendingNoticeEmpty(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + if got := composePendingNotice(); got != nil { + // update/skills pending are process-global; only assert the absence of + // our own key to stay robust against unrelated pending state. + if _, ok := got["deprecated_command"]; ok { + t.Fatalf("deprecated_command present after clear: %#v", got) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 4a988743a..b7321ae74 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/hook" @@ -149,29 +150,46 @@ func setupNotices() { skillscheck.Init(build.Version) // Composed notice provider — emits keys only when each pending is set. - output.PendingNotice = func() map[string]interface{} { - notice := map[string]interface{}{} - if info := update.GetPending(); info != nil { - notice["update"] = map[string]interface{}{ - "current": info.Current, - "latest": info.Latest, - "message": info.Message(), - "command": "lark-cli update", - } + output.PendingNotice = composePendingNotice +} + +// composePendingNotice merges all process-level pending notices (available +// update, skills/binary drift, deprecated-command alias) into the map surfaced +// as the JSON "_notice" envelope field. Returns nil when nothing is pending. +// Extracted from Execute so the composition is unit-testable. +func composePendingNotice() map[string]interface{} { + notice := map[string]interface{}{} + if info := update.GetPending(); info != nil { + notice["update"] = map[string]interface{}{ + "current": info.Current, + "latest": info.Latest, + "message": info.Message(), + "command": "lark-cli update", } - if stale := skillscheck.GetPending(); stale != nil { - notice["skills"] = map[string]interface{}{ - "current": stale.Current, - "target": stale.Target, - "message": stale.Message(), - "command": "lark-cli update", - } - } - if len(notice) == 0 { - return nil - } - return notice } + if stale := skillscheck.GetPending(); stale != nil { + notice["skills"] = map[string]interface{}{ + "current": stale.Current, + "target": stale.Target, + "message": stale.Message(), + "command": "lark-cli update", + } + } + if dep := deprecation.GetPending(); dep != nil { + entry := map[string]interface{}{ + "command": dep.Command, + "message": dep.Message(), + "action": "lark-cli update", + } + if dep.Replacement != "" { + entry["replacement"] = dep.Replacement + } + notice["deprecated_command"] = entry + } + if len(notice) == 0 { + return nil + } + return notice } // isCompletionCommand returns true if args indicate a shell completion request. @@ -338,32 +356,45 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { return cmd.Help() } unknown := args[0] - available := availableSubcommandNames(cmd) - suggestions := suggest.Closest(unknown, available, 6) + available, deprecated := availableSubcommandNames(cmd) + // Rank suggestions across both current and deprecated names so a mistyped + // legacy command (e.g. +raed → +read) still resolves; the alias stays + // runnable and self-flags via the _notice on execution. + suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) if len(suggestions) > 0 { hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", strings.Join(suggestions, ", "), cmd.CommandPath()) } + detail := map[string]any{ + "unknown": unknown, + "command_path": cmd.CommandPath(), + "suggestions": suggestions, + "available": available, + } + // Only services with backward-compat aliases (currently sheets) carry a + // deprecated bucket; omit the key elsewhere so every other service's + // envelope is unchanged. + if len(deprecated) > 0 { + detail["deprecated"] = deprecated + } return &output.ExitError{ Code: output.ExitValidation, Detail: &output.ErrDetail{ Type: "unknown_subcommand", Message: msg, Hint: hint, - Detail: map[string]any{ - "unknown": unknown, - "command_path": cmd.CommandPath(), - "suggestions": suggestions, - "available": available, - }, + Detail: detail, }, } } -func availableSubcommandNames(cmd *cobra.Command) []string { - subs := make([]string, 0, len(cmd.Commands())) +// availableSubcommandNames returns the invokable subcommand names of cmd, split +// into current commands and backward-compatibility aliases (those tagged into +// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are +// sorted; hidden commands plus help/completion are omitted. +func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) { for _, c := range cmd.Commands() { if c.Hidden || !c.IsAvailableCommand() { continue @@ -372,10 +403,15 @@ func availableSubcommandNames(cmd *cobra.Command) []string { if name == "help" || name == "completion" { continue } - subs = append(subs, name) + if cmdutil.IsDeprecatedCommand(c) { + deprecated = append(deprecated, name) + } else { + available = append(available, name) + } } - sort.Strings(subs) - return subs + sort.Strings(available) + sort.Strings(deprecated) + return available, deprecated } // flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 2cc6f2d84..67c1a3e59 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" ) @@ -164,7 +165,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { &cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }}, ) - got := availableSubcommandNames(root) + got, _ := availableSubcommandNames(root) want := []string{"alpha", "gamma"} if len(got) != len(want) { t.Fatalf("expected %v, got %v", want, got) @@ -175,3 +176,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { } } } + +func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) + root.AddCommand( + &cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + available, deprecated := availableSubcommandNames(root) + if len(available) != 1 || available[0] != "+new-cmd" { + t.Errorf("available = %v, want [+new-cmd]", available) + } + if len(deprecated) != 1 || deprecated[0] != "+old-cmd" { + t.Errorf("deprecated = %v, want [+old-cmd]", deprecated) + } +} + +// unknownSubcommandRunE must split current vs deprecated subcommands into +// separate detail buckets, while suggestions still rank across both so a +// mistyped legacy alias resolves. +func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) { + svc := &cobra.Command{Use: "sheets"} + svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"}) + svc.AddCommand( + &cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + err := unknownSubcommandRunE(svc, []string{"+reat"}) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail) + } + + if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" { + t.Errorf("available = %v, want [+cells-get]", available) + } + deprecated, ok := detail["deprecated"].([]string) + if !ok || len(deprecated) != 1 || deprecated[0] != "+read" { + t.Errorf("deprecated = %v, want [+read]", deprecated) + } + // suggestions rank across both buckets: "+reat" is closest to +read. + suggestions, _ := detail["suggestions"].([]string) + found := false + for _, s := range suggestions { + if s == "+read" { + found = true + } + } + if !found { + t.Errorf("suggestions %v should include +read (typo target)", suggestions) + } +} diff --git a/internal/cmdutil/groups.go b/internal/cmdutil/groups.go new file mode 100644 index 000000000..5045f555b --- /dev/null +++ b/internal/cmdutil/groups.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "github.com/spf13/cobra" + +// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility +// command — one kept alive for users whose skill predates a refactor. Service +// registration assigns it (e.g. the sheets pre-refactor aliases); both --help +// rendering and unknown-subcommand suggestions read it to separate these +// aliases from the current commands. +const DeprecatedGroupID = "deprecated" + +// IsDeprecatedCommand reports whether c was tagged into the deprecated group. +func IsDeprecatedCommand(c *cobra.Command) bool { + return c != nil && c.GroupID == DeprecatedGroupID +} diff --git a/internal/deprecation/deprecation.go b/internal/deprecation/deprecation.go new file mode 100644 index 000000000..ad5b4be5b --- /dev/null +++ b/internal/deprecation/deprecation.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package deprecation carries a process-level notice that the command currently +// being executed is a backward-compatibility alias, kept alive for users whose +// skill predates a refactor. The notice is surfaced in JSON output envelopes via +// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck. +// +// A CLI process runs exactly one shortcut, so a single process-level slot is +// sufficient: the command's Execute records the notice before producing output, +// and the output layer reads it back when building the envelope. +package deprecation + +import ( + "strings" + "sync/atomic" +) + +// Notice describes a deprecated command alias and the current command that +// replaces it. Replacement and Skill are optional. +type Notice struct { + Command string `json:"command"` + Replacement string `json:"replacement,omitempty"` + Skill string `json:"skill,omitempty"` +} + +// Message returns a single-line, AI-agent-parseable description of the alias +// plus the canonical fix (update the skill). Mirrors the style of +// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update"). +func (n *Notice) Message() string { + var b strings.Builder + b.WriteString(n.Command) + b.WriteString(" is a pre-refactor compatibility alias") + if n.Replacement != "" { + b.WriteString("; use ") + b.WriteString(n.Replacement) + b.WriteString(" instead") + } + if n.Skill != "" { + b.WriteString("; update your ") + b.WriteString(n.Skill) + b.WriteString(" skill, run: lark-cli update") + } else { + b.WriteString("; update your skill, run: lark-cli update") + } + return b.String() +} + +// pending stores the latest deprecation notice for the current process. +var pending atomic.Pointer[Notice] + +// SetPending stores the notice for consumption by output decorators. +// Pass nil to clear. +func SetPending(n *Notice) { pending.Store(n) } + +// GetPending returns the pending deprecation notice, or nil. +func GetPending() *Notice { return pending.Load() } diff --git a/internal/deprecation/deprecation_test.go b/internal/deprecation/deprecation_test.go new file mode 100644 index 000000000..69237c9f4 --- /dev/null +++ b/internal/deprecation/deprecation_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package deprecation + +import "testing" + +func TestNoticeMessage(t *testing.T) { + tests := []struct { + name string + notice Notice + want string + }{ + { + name: "replacement and skill", + notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"}, + want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update", + }, + { + name: "no replacement", + notice: Notice{Command: "+read", Skill: "lark-sheets"}, + want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update", + }, + { + name: "no skill", + notice: Notice{Command: "+read", Replacement: "+cells-get"}, + want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.notice.Message(); got != tt.want { + t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want) + } + }) + } +} + +func TestSetGetPending(t *testing.T) { + t.Cleanup(func() { SetPending(nil) }) + + SetPending(nil) + if got := GetPending(); got != nil { + t.Fatalf("expected nil pending after clear, got %#v", got) + } + + n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"} + SetPending(n) + got := GetPending() + if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" { + t.Fatalf("GetPending() = %#v, want %#v", got, n) + } + + SetPending(nil) + if GetPending() != nil { + t.Fatal("expected nil after clearing") + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index e0b140163..c5cfee5fe 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/apps" @@ -69,7 +70,7 @@ func init() { // kept under shortcuts/sheets/backward so external callers relying on the // old `+create`, `+read`, `+write`, ... commands keep working alongside the // refactored ones. Command names are disjoint from sheets.Shortcuts(). - allShortcuts = append(allShortcuts, sheetsbackward.Shortcuts()...) + allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...) allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) @@ -152,6 +153,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f if service == "mail" { mail.InstallOnMail(svc) } + if service == "sheets" { + applySheetsCompatGroups(svc) + } if !IsShortcutServiceAvailable(service, brand) { installBrandRestrictionGuard(svc, service, brand) @@ -195,3 +199,146 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core // --help bypasses RunE, so surface the restriction in Long too. svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand) } + +// Sheets backward-compatibility help grouping. +// +// shortcuts/sheets/backward keeps the pre-refactor command names alive so that +// users whose lark-sheets skill predates the refactor keep working even after +// upgrading only the binary. In `sheets --help` those aliases would otherwise +// sort alphabetically into the same flat list as the current commands, +// indistinguishable from them. applySheetsCompatGroups splits them into a +// dedicated cobra group whose heading tells the user to update their skill, and +// appends a "(→ +new-command)" pointer to each alias so the migration target is +// obvious. Pure presentation — the aliases stay fully executable. +const ( + sheetsCurrentGroupID = "sheets-current" + // sheetsDeprecatedGroupID aliases the shared deprecated-group id so both + // `sheets --help` grouping and the generic unknown-subcommand path + // (cmd/root.go) classify these aliases the same way. + sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID +) + +// sheetsAliasReplacement maps each pre-refactor sheets alias to the current +// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases +// absent from this map still land in the deprecated group, just without a +// pointer, so a missing entry degrades gracefully rather than misgrouping. +var sheetsAliasReplacement = map[string]string{ + // spreadsheet / sheet management + "+create": "+workbook-create", + "+info": "+workbook-info", + "+export": "+workbook-export", + "+create-sheet": "+sheet-create", + "+copy-sheet": "+sheet-copy", + "+delete-sheet": "+sheet-delete", + "+update-sheet": "+sheet-rename / +sheet-move / …", + // cell data + "+read": "+cells-get", + "+write": "+cells-set", + "+append": "+cells-set", + "+find": "+cells-search", + "+replace": "+cells-replace", + // cell style / merge / image + "+set-style": "+cells-set-style", + "+batch-set-style": "+cells-batch-set-style", + "+merge-cells": "+cells-merge", + "+unmerge-cells": "+cells-unmerge", + "+write-image": "+cells-set-image", + // row / column dimensions + "+add-dimension": "+dim-insert", + "+insert-dimension": "+dim-insert", + "+update-dimension": "+rows-resize / +dim-hide / …", + "+move-dimension": "+dim-move", + "+delete-dimension": "+dim-delete", + // filter views (conditions folded into the view flags) + "+create-filter-view": "+filter-view-create", + "+update-filter-view": "+filter-view-update", + "+list-filter-views": "+filter-view-list", + "+get-filter-view": "+filter-view-list", + "+delete-filter-view": "+filter-view-delete", + "+create-filter-view-condition": "+filter-view-update", + "+update-filter-view-condition": "+filter-view-update", + "+list-filter-view-conditions": "+filter-view-list", + "+get-filter-view-condition": "+filter-view-list", + "+delete-filter-view-condition": "+filter-view-update", + // dropdowns + "+set-dropdown": "+dropdown-set", + "+update-dropdown": "+dropdown-update", + "+get-dropdown": "+dropdown-get", + "+delete-dropdown": "+dropdown-delete", + // float images (media-upload folded into create) + "+media-upload": "+float-image-create", + "+create-float-image": "+float-image-create", + "+update-float-image": "+float-image-update", + "+get-float-image": "+float-image-list", + "+list-float-images": "+float-image-list", + "+delete-float-image": "+float-image-delete", +} + +func applySheetsCompatGroups(svc *cobra.Command) { + svc.AddGroup( + &cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"}, + &cobra.Group{ + ID: sheetsDeprecatedGroupID, + Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update", + }, + ) + + deprecated := make(map[string]struct{}) + for _, s := range sheetsbackward.Shortcuts() { + deprecated[s.Command] = struct{}{} + } + + for _, c := range svc.Commands() { + name := c.Name() + if _, ok := deprecated[name]; ok { + c.GroupID = sheetsDeprecatedGroupID + if repl := sheetsAliasReplacement[name]; repl != "" { + c.Short = c.Short + " (→ " + repl + ")" + } + continue + } + // Only the refactored shortcuts (all "+"-prefixed) belong in the current + // group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the + // auto-added help/completion ungrouped so cobra files them under + // "Additional Commands". + if len(name) > 0 && name[0] == '+' { + c.GroupID = sheetsCurrentGroupID + } + } +} + +// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets +// alias so that invoking it records a process-level deprecation notice, which +// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users +// the --help grouping cannot: those whose pre-refactor skill calls +read / +// +write directly and never reads --help. Replacement targets come from +// sheetsAliasReplacement — the same single source of truth that drives the +// "(→ +new)" help pointers. +func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut { + for i := range list { + notice := &deprecation.Notice{ + Command: list[i].Command, + Replacement: sheetsAliasReplacement[list[i].Command], + Skill: "lark-sheets", + } + // Record the notice as soon as the command's own logic runs, so it is + // surfaced even when Validate rejects the call — an out-of-date skill + // can pass pre-refactor argument shapes (e.g. a range without the new + // sheet-id prefix) and fail validation before Execute — and when + // --dry-run short-circuits before Execute. Both hooks store the same + // pointer, so setting it twice is harmless. + if origValidate := list[i].Validate; origValidate != nil { + list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error { + deprecation.SetPending(notice) + return origValidate(ctx, runtime) + } + } + if origExecute := list[i].Execute; origExecute != nil { + list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + deprecation.SetPending(notice) + return origExecute(ctx, runtime) + } + } + } + return list +} diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 82b067c62..4f7cb52d0 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -5,6 +5,7 @@ package shortcuts import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -16,7 +17,9 @@ import ( "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -471,3 +474,152 @@ func TestGenerateShortcutsJSON(t *testing.T) { } t.Logf("wrote %d bytes to %s", len(data), output) } + +// applySheetsCompatGroups must split the sheets service into a current group +// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases), +// append a "(→ +new)" migration pointer to each alias, and leave non-"+" +// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them +// under "Additional Commands". +func TestApplySheetsCompatGroups(t *testing.T) { + svc := &cobra.Command{Use: "sheets"} + newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"} + aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"} + metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"} + svc.AddCommand(newCmd, aliasCmd, metaCmd) + + applySheetsCompatGroups(svc) + + if !svc.ContainsGroup(sheetsCurrentGroupID) { + t.Errorf("current group %q not registered", sheetsCurrentGroupID) + } + if !svc.ContainsGroup(sheetsDeprecatedGroupID) { + t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID) + } + if newCmd.GroupID != sheetsCurrentGroupID { + t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID) + } + if aliasCmd.GroupID != sheetsDeprecatedGroupID { + t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID) + } + if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") { + t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short) + } + if metaCmd.GroupID != "" { + t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID) + } +} + +// End-to-end: the rendered `sheets --help` must surface the deprecated-group +// heading (telling users to update their skill) plus the per-alias migration +// pointers, while keeping the refactored shortcuts under Available Commands. +func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + sheetsCmd, _, err := program.Find([]string{"sheets"}) + if err != nil { + t.Fatalf("find sheets command: %v", err) + } + + var out bytes.Buffer + sheetsCmd.SetOut(&out) + if err := sheetsCmd.Help(); err != nil { + t.Fatalf("sheets help failed: %v", err) + } + got := out.String() + + for _, want := range []string{ + "Available Commands:", + "Deprecated pre-refactor commands", + "update your lark-sheets skill", + "+read", + "(→ +cells-get)", + "+write", + "(→ +cells-set)", + } { + if !strings.Contains(got, want) { + t.Fatalf("sheets help missing %q:\n%s", want, got) + } + } +} + +// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that +// invoking it records a process-level deprecation notice (reusing +// sheetsAliasReplacement for the migration target) while still calling the +// original Execute. cmd/root.go reads that notice into the JSON "_notice". +func TestWrapSheetsBackwardDeprecation(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + called := false + in := []common.Shortcut{{ + Service: "sheets", + Command: "+read", + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + called = true + return nil + }, + }} + + out := wrapSheetsBackwardDeprecation(in) + if len(out) != 1 { + t.Fatalf("wrapped list len = %d, want 1", len(out)) + } + if deprecation.GetPending() != nil { + t.Fatal("notice set before wrapped Execute ran") + } + + if err := out[0].Execute(context.Background(), nil); err != nil { + t.Fatalf("wrapped Execute returned error: %v", err) + } + if !called { + t.Fatal("original Execute was not invoked by the wrapper") + } + + dep := deprecation.GetPending() + if dep == nil { + t.Fatal("expected a pending deprecation notice after Execute") + } + if dep.Command != "+read" { + t.Errorf("notice Command = %q, want +read", dep.Command) + } + if dep.Replacement != "+cells-get" { + t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement) + } + if dep.Skill != "lark-sheets" { + t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill) + } +} + +// The wrapper must also decorate Validate, so an out-of-date skill whose +// pre-refactor argument shape fails validation (before Execute) still gets the +// deprecation notice in its error envelope. +func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) { + t.Cleanup(func() { deprecation.SetPending(nil) }) + deprecation.SetPending(nil) + + validated := false + in := []common.Shortcut{{ + Service: "sheets", + Command: "+write", + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + validated = true + return nil + }, + }} + + out := wrapSheetsBackwardDeprecation(in) + if out[0].Validate == nil { + t.Fatal("Validate hook was dropped by the wrapper") + } + if err := out[0].Validate(context.Background(), nil); err != nil { + t.Fatalf("wrapped Validate returned error: %v", err) + } + if !validated { + t.Fatal("original Validate was not invoked") + } + dep := deprecation.GetPending() + if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" { + t.Fatalf("Validate hook did not record expected notice: %#v", dep) + } +}