feat: add --skills flag to update for suite mode

This commit is contained in:
zhangheng.023
2026-06-26 12:09:07 +08:00
parent eab2063131
commit 95ee561bf0
2 changed files with 235 additions and 18 deletions

View File

@@ -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, ", "))
}
}

View File

@@ -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")
}
}