From 95ee561bf0b63e7488901dd470ae2e33d5b073db Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Fri, 26 Jun 2026 12:09:07 +0800 Subject: [PATCH] feat: add --skills flag to update for suite mode --- cmd/update/update.go | 70 ++++++++++++--- cmd/update/update_test.go | 183 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 235 insertions(+), 18 deletions(-) diff --git a/cmd/update/update.go b/cmd/update/update.go index 43a047b2e..598d65dc3 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -86,10 +86,12 @@ func symArrow() string { // UpdateOptions holds inputs for the update command. type UpdateOptions struct { - Factory *cmdutil.Factory - JSON bool - Force bool - Check bool + Factory *cmdutil.Factory + JSON bool + Force bool + Check bool + Skills []string + SuiteProvided bool } // NewCmdUpdate creates the update command. @@ -108,6 +110,7 @@ Detects the installation method automatically: Use --json for structured output (for AI agents and scripts). Use --check to only check for updates without installing.`, RunE: func(cmd *cobra.Command, args []string) error { + opts.SuiteProvided = cmd.Flags().Changed("skills") return updateRun(opts) }, } @@ -115,6 +118,8 @@ Use --check to only check for updates without installing.`, cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date") cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install") + cmd.Flags().StringSliceVar(&opts.Skills, "skills", nil, + "comma-separated lark skill names to install and remember (the suite); use --skills all to reset to all official skills") cmdutil.SetRisk(cmd, "high-risk-write") return cmd @@ -125,6 +130,18 @@ func updateRun(opts *UpdateOptions) error { cur := currentVersion() updater := newUpdater() + // 早期格式校验:在任何网络/安装动作之前 fail-fast。 + var suite *skillscheck.SuiteSelection + if opts.SuiteProvided { + parsed, err := skillscheck.ParseSuiteSelection(opts.Skills) + if err != nil { + return reportError(opts, io, "validation", + errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err). + WithHint("e.g. --skills lark-calendar,lark-im (or --skills all to reset)")) + } + suite = parsed + } + if !opts.Check { updater.CleanupStaleFiles() } @@ -147,7 +164,10 @@ func updateRun(opts *UpdateOptions) error { if !opts.Force && !update.IsNewer(latest, cur) { var skillsResult *skillscheck.SyncResult if !opts.Check { - skillsResult = runSkillsAndState(updater, io, cur, opts.Force) + skillsResult = runSkillsAndState(updater, io, cur, opts.Force, suite) + if err := suiteInputError(opts, io, skillsResult); err != nil { + return err + } } return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check) } @@ -162,9 +182,9 @@ func updateRun(opts *UpdateOptions) error { // 6. Execute update if !detect.CanAutoUpdate() { - return doManualUpdate(opts, io, cur, latest, detect, updater) + return doManualUpdate(opts, io, cur, latest, detect, updater, suite) } - return doNpmUpdate(opts, io, cur, latest, updater) + return doNpmUpdate(opts, io, cur, latest, updater, suite) } // --- Output helpers --- @@ -207,8 +227,11 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s return nil } -func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error { - skillsResult := runSkillsAndState(updater, io, cur, opts.Force) +func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater, suite *skillscheck.SuiteSelection) error { + skillsResult := runSkillsAndState(updater, io, cur, opts.Force, suite) + if err := suiteInputError(opts, io, skillsResult); err != nil { + return err + } reason := detect.ManualReason() if opts.JSON { @@ -231,7 +254,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri return nil } -func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error { +func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater, suite *skillscheck.SuiteSelection) error { restore, err := updater.PrepareSelfReplace() if err != nil { return reportError(opts, io, "update_error", @@ -287,7 +310,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, return output.ErrBare(output.ExitAPI) } - skillsResult := runSkillsAndState(updater, io, latest, opts.Force) + skillsResult := runSkillsAndState(updater, io, latest, opts.Force, suite) + if err := suiteInputError(opts, io, skillsResult); err != nil { + return err + } if opts.JSON { result := map[string]interface{}{ @@ -324,8 +350,8 @@ 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)) } -func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult { - if !force { +func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, suite *skillscheck.SuiteSelection) *skillscheck.SyncResult { + if !force && suite == nil { if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) { return nil } @@ -334,6 +360,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state Version: stateVersion, Force: force, Runner: updater, + Suite: suite, }) if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") { fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err) @@ -341,6 +368,17 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state return result } +// suiteInputError 把 suite 名字非法(InvalidInput)的 sync 结果映射为退出码 2 的 validation 错误。 +// 返回 nil 表示不是输入错误,调用方继续正常输出流程。 +func suiteInputError(opts *UpdateOptions, io *cmdutil.IOStreams, r *skillscheck.SyncResult) error { + if r == nil || !r.InvalidInput { + return nil + } + return reportError(opts, io, "validation", + errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", r.Err). + WithHint("use a valid official skill name; nothing was installed")) +} + // reportAlreadyUpToDate emits the JSON / pretty output for the // already-up-to-date branch, including any skills_action / skills_warning // fields derived from skillsResult. When check is true, this is the pure @@ -402,6 +440,9 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) { env["skills_action"] = "synced" env["skills_summary"] = skillsSummary(r) } + if r != nil && len(r.Suite) > 0 { + env["skills_suite"] = r.Suite + } } func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} { @@ -434,4 +475,7 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) { fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n") } } + if r != nil && len(r.Suite) > 0 { + fmt.Fprintf(io.ErrOut, " Suite: %s (run `lark-cli update --skills all` to restore all)\n", strings.Join(r.Suite, ", ")) + } } diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index faf2f7629..6a82e22ca 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "os/exec" + "reflect" "strings" "testing" "time" @@ -1006,7 +1007,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) { return &selfupdate.NpmResult{} }, } - got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil) if got != nil { t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got) } @@ -1027,7 +1028,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { return successfulSkillsCommand()(args...) }, } - got := runSkillsAndState(updater, newTestIO(), "1.0.21", true) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, nil) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got) } @@ -1039,7 +1040,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { 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) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got) } @@ -1064,7 +1065,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) { return r }, } - got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, nil) if got == nil || got.Err == nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got) } @@ -1357,7 +1358,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) { t.Cleanup(func() { syncSkills = origSync }) f, _, stderr := newTestFactory(t) - got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false) + got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, nil) if got == nil || got.Err == nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got) } @@ -1594,3 +1595,175 @@ func containsString(values []string, target string) bool { } return false } + +// captureSyncSkills 替换 syncSkills,记录传入的 SyncOptions 并返回固定结果。 +func captureSyncSkills(t *testing.T, result *skillscheck.SyncResult) *skillscheck.SyncOptions { + t.Helper() + var captured skillscheck.SyncOptions + orig := syncSkills + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { + captured = opts + return result + } + t.Cleanup(func() { syncSkills = orig }) + return &captured +} + +func TestUpdate_SkillsFlagParsedIntoSuite(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--skills", "lark-calendar,lark-im"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } // already up to date path + defer func() { currentVersion = origVersion }() + + captured := captureSyncSkills(t, &skillscheck.SyncResult{ + Action: "synced", Official: []string{"lark-calendar", "lark-im"}, + Updated: []string{"lark-calendar", "lark-im"}, Suite: []string{"lark-calendar", "lark-im"}, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() err = %v", err) + } + if captured.Suite == nil || captured.Suite.All { + t.Fatalf("captured.Suite = %#v, want explicit list", captured.Suite) + } + if !reflect.DeepEqual(captured.Suite.Skills, []string{"lark-calendar", "lark-im"}) { + t.Fatalf("captured.Suite.Skills = %#v, want [lark-calendar lark-im]", captured.Suite.Skills) + } +} + +func TestUpdate_SkillsAllParsedAsReset(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--skills", "all"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + captured := captureSyncSkills(t, &skillscheck.SyncResult{Action: "synced"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() err = %v", err) + } + if captured.Suite == nil || !captured.Suite.All { + t.Fatalf("captured.Suite = %#v, want All=true", captured.Suite) + } +} + +func TestUpdate_InvalidSkillsFlag_JSONExit2(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--skills", "all,lark-im"}) + + // fetchLatest must NOT be called — validation happens first. + origFetch := fetchLatest + fetchLatest = func() (string, error) { + t.Fatal("fetchLatest called before --skills validation") + return "", nil + } + defer func() { fetchLatest = origFetch }() + + err := cmd.Execute() + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation) + } + if !strings.Contains(stdout.String(), `"type": "validation"`) { + t.Fatalf("JSON output missing validation type: %s", stdout.String()) + } +} + +func TestUpdate_UnknownSkillResult_Exit2(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--skills", "lark-bogus"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + captureSyncSkills(t, &skillscheck.SyncResult{ + Action: "failed", InvalidInput: true, + Err: errors.New("unknown skill(s) not in official list: lark-bogus"), + }) + + err := cmd.Execute() + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", got, output.ExitValidation) + } +} + +func TestUpdate_SkillsSuiteInJSONOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--json", "--skills", "lark-im"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + captureSyncSkills(t, &skillscheck.SyncResult{ + Action: "synced", Official: []string{"lark-im"}, Updated: []string{"lark-im"}, + Suite: []string{"lark-im"}, + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() err = %v", err) + } + if !strings.Contains(stdout.String(), `"skills_suite"`) { + t.Fatalf("JSON output missing skills_suite: %s", stdout.String()) + } +} + +func TestUpdate_SkillsFlagBypassesVersionEarlyReturn(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + // State synced at the same version → without --skills this would skip sync. + if err := skillscheck.WriteState(skillscheck.SkillsState{ + Version: "1.0.0", UpdatedAt: "2026-06-26T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + f, _, _ := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{"--skills", "lark-im"}) + + origFetch := fetchLatest + fetchLatest = func() (string, error) { return "1.0.0", nil } + defer func() { fetchLatest = origFetch }() + origVersion := currentVersion + currentVersion = func() string { return "1.0.0" } + defer func() { currentVersion = origVersion }() + + called := false + orig := syncSkills + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { + called = true + return &skillscheck.SyncResult{Action: "synced", Suite: []string{"lark-im"}} + } + t.Cleanup(func() { syncSkills = orig }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() err = %v", err) + } + if !called { + t.Fatal("syncSkills not called — --skills must bypass the same-version early return") + } +}