diff --git a/cmd/update/update.go b/cmd/update/update.go index 43a047b2e..691f873ac 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,22 +182,26 @@ 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 --- // reportError emits the failure on the requested surface: JSON mode prints the -// {ok:false, error:{type, message}} envelope to stdout and signals the typed -// error's exit code bare; human mode returns the typed error for the -// dispatcher to render. +// {ok:false, error:{type, message, hint?}} envelope to stdout and signals the +// typed error's exit code bare; human mode returns the typed error for the +// dispatcher to render. The hint is included only when the typed error carries +// one, so AI-agent/script consumers reading JSON get the same actionable +// guidance humans see on stderr. func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error { if opts.JSON { - output.PrintJson(io.Out, map[string]interface{}{ - "ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message}, - }) + errObj := map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message} + if hint := typedErr.ProblemDetail().Hint; hint != "" { + errObj["hint"] = hint + } + output.PrintJson(io.Out, map[string]interface{}{"ok": false, "error": errObj}) return output.ErrBare(output.ExitCodeOf(typedErr)) } return typedErr @@ -207,8 +231,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 +258,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 +314,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 +354,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 +364,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 +372,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 +444,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 +479,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..80798919b 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,179 @@ 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()) + } + // spec §3.8: JSON validation errors must carry the actionable hint too. + if !strings.Contains(stdout.String(), `"hint"`) { + t.Fatalf("JSON output missing hint field: %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") + } +} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index eddab1cf3..e6a51f02d 100644 --- a/internal/skillscheck/state.go +++ b/internal/skillscheck/state.go @@ -27,6 +27,7 @@ type SkillsState struct { UpdatedSkills []string `json:"updated_skills"` AddedOfficialSkills []string `json:"added_official_skills"` SkippedDeletedSkills []string `json:"skipped_deleted_skills"` + SuiteSkills []string `json:"suite_skills,omitempty"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/skillscheck/state_test.go b/internal/skillscheck/state_test.go index d69b74635..b82173394 100644 --- a/internal/skillscheck/state_test.go +++ b/internal/skillscheck/state_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" ) @@ -137,3 +138,57 @@ func TestReadSyncedVersionFromState(t *testing.T) { t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok) } } + +func TestSkillsStateSuiteRoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + if err := WriteState(SkillsState{ + Version: "1.2.3", + SuiteSkills: []string{"lark-calendar", "lark-im"}, + UpdatedAt: "2026-06-26T10:00:00Z", + }); err != nil { + t.Fatalf("WriteState() err = %v, want nil", err) + } + + got, ok, err := ReadState() + if err != nil || !ok || got == nil { + t.Fatalf("ReadState() = (_, %v, %v), want readable state", ok, err) + } + if !reflect.DeepEqual(got.SuiteSkills, []string{"lark-calendar", "lark-im"}) { + t.Fatalf("SuiteSkills = %#v, want [lark-calendar lark-im]", got.SuiteSkills) + } +} + +func TestSkillsStateSuiteBackCompat(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + // Old state file without suite_skills field. + old := `{"version":"1.0.0","official_skills":["lark-im"],"updated_at":"2026-01-01T00:00:00Z"}` + if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(old), 0o644); err != nil { + t.Fatal(err) + } + + got, ok, err := ReadState() + if err != nil || !ok || got == nil { + t.Fatalf("ReadState() = (_, %v, %v), want readable", ok, err) + } + if got.SuiteSkills != nil { + t.Fatalf("SuiteSkills = %#v, want nil for old state without the field", got.SuiteSkills) + } +} + +func TestWriteStateOmitsEmptySuite(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := WriteState(SkillsState{Version: "1.0.0"}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, stateFile)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "suite_skills") { + t.Fatalf("empty suite must be omitted from JSON, got: %s", raw) + } +} diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go index 2f8adb3db..281b680b3 100644 --- a/internal/skillscheck/sync.go +++ b/internal/skillscheck/sync.go @@ -28,6 +28,56 @@ type SyncInput struct { Force bool } +// SuiteSelection 表示本次调用通过 --skills 传入的 suite 选择。 +// All 为 true 表示 "--skills all"(重置为全部官方 skill);否则 Skills 为显式名单。 +type SuiteSelection struct { + All bool + Skills []string +} + +// ParseSuiteSelection 解析 --skills 的原始值,只做格式校验(不校验名字是否是真实官方 skill)。 +// 调用方仅在用户显式传入 --skills 时调用本函数。 +func ParseSuiteSelection(rawNames []string) (*SuiteSelection, error) { + seen := map[string]bool{} + cleaned := []string{} + hasAll := false + for _, raw := range rawNames { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + if strings.EqualFold(name, "all") { + hasAll = true + continue + } + if seen[name] { + continue + } + seen[name] = true + cleaned = append(cleaned, name) + } + if hasAll { + if len(cleaned) > 0 { + return nil, fmt.Errorf("--skills all cannot be combined with other skill names") + } + return &SuiteSelection{All: true}, nil + } + if len(cleaned) == 0 { + return nil, fmt.Errorf("--skills requires at least one skill name") + } + invalid := []string{} + for _, name := range cleaned { + if !skillNamePattern.MatchString(name) { + invalid = append(invalid, name) + } + } + if len(invalid) > 0 { + return nil, fmt.Errorf("invalid skill name(s): %s (skill names use only letters, digits, and _ : -)", strings.Join(invalid, ", ")) + } + sort.Strings(cleaned) + return &SuiteSelection{Skills: cleaned}, nil +} + type SyncPlan struct { Version string OfficialSkills []string @@ -259,6 +309,7 @@ type SyncOptions struct { Force bool Runner SkillsRunner Now func() time.Time + Suite *SuiteSelection // nil = 本次未传 --skills(沿用 state 中的 sticky suite) } type SyncResult struct { @@ -271,6 +322,31 @@ type SyncResult struct { Err error Detail string Force bool + Suite []string // 生效的 suite(nil/空 = 全部模式) + InvalidInput bool // true 表示因用户输入非法(未知 skill 名)而失败 → 命令层映射为 exit 2 +} + +// resolveEffectiveSuite 决定本次实际生效的 suite。 +// 返回 (suite 名单, suiteActive)。suiteActive=false 表示全部模式。 +func resolveEffectiveSuite(optSuite *SuiteSelection, previous *SkillsState, readable bool) ([]string, bool) { + if optSuite != nil { + if optSuite.All { + return nil, false // 显式重置为全部 + } + return optSuite.Skills, true + } + if readable && previous != nil && len(previous.SuiteSkills) > 0 { + return previous.SuiteSkills, true // 沿用 sticky suite + } + return nil, false +} + +// suiteSkillsForState 返回写入 state 的 SuiteSkills(全部模式时为 nil,使其被 omitempty 省略/清空)。 +func suiteSkillsForState(active bool, suite []string) []string { + if !active { + return nil + } + return suite } func SyncSkills(opts SyncOptions) *SyncResult { @@ -281,25 +357,67 @@ func SyncSkills(opts SyncOptions) *SyncResult { return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")} } + // 先读 previous state——解析 sticky suite 需要它,且即便后续官方列表失败也要能判断是否处于 suite 模式。 + previous, readable, err := ReadState() + if err != nil { + readable = false + previous = nil + } + + effectiveSuite, suiteActive := resolveEffectiveSuite(opts.Suite, previous, readable) + // --- Step 1: List official skills --- official, reason, ok := listOfficialSkills(opts.Runner) if !ok { + if suiteActive { + // suite 模式绝不 fallback 装全部(会违背"只要子集"的意图)。 + return &SyncResult{ + Action: "failed", + Err: fmt.Errorf("cannot apply skills suite: official skills list unavailable (%s)", reason), + Detail: reason, + Force: opts.Force, + Suite: effectiveSuite, + } + } return fallbackFullInstall(opts, reason, nil) } + // --- Step 1.5: suite 模式下校验名字 + 收窄官方集合 --- + if suiteActive { + officialSet := toSet(official) + unknown := []string{} + for _, name := range effectiveSuite { + if !officialSet[name] { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + return &SyncResult{ + Action: "failed", + InvalidInput: true, + Err: fmt.Errorf("unknown skill(s) not in official list: %s", strings.Join(unknown, ", ")), + Force: opts.Force, + Suite: effectiveSuite, + } + } + official = intersection(official, toSet(effectiveSuite)) + } + // --- Step 2: List local (installed) skills --- local, ok := listLocalSkills(opts.Runner) if !ok { + if suiteActive { + return &SyncResult{ + Action: "failed", + Err: fmt.Errorf("cannot apply skills suite: local skills list unavailable"), + Force: opts.Force, + Suite: effectiveSuite, + } + } return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official) } - // --- Step 3: Read previous state --- - previous, readable, err := ReadState() - if err != nil { - readable = false - previous = nil - } - + // --- Step 3: Plan (previous state already read above) --- plan := PlanSync(SyncInput{ Version: opts.Version, OfficialSkills: official, @@ -309,32 +427,49 @@ func SyncSkills(opts SyncOptions) *SyncResult { Force: opts.Force, }) + toInstall := plan.ToUpdate + // suite 模式:若增量计算出"无需更新",仍要确保 suite 被安装(用户显式要这些 skill)。 + if suiteActive && len(toInstall) == 0 { + toInstall = official + } + result := &SyncResult{ Action: "synced", Official: plan.OfficialSkills, - Updated: plan.ToUpdate, + Updated: toInstall, Added: plan.Added, SkippedDeleted: plan.SkippedDeleted, Force: opts.Force, + Suite: suiteSkillsForState(suiteActive, effectiveSuite), } - if len(plan.ToUpdate) == 0 { + if len(toInstall) == 0 { + // 仅非 suite 模式才会到这里。 return fallbackFullInstall(opts, "toUpdate skills empty fallback", official) } - if len(plan.ToUpdate) > 0 { - installResult := opts.Runner.InstallSkill(plan.ToUpdate) - if installResult == nil || installResult.Err != nil { - return fallbackFullInstall(opts, resultDetail(installResult), official) + installResult := opts.Runner.InstallSkill(toInstall) + if installResult == nil || installResult.Err != nil { + if suiteActive { + // suite 模式安装失败也不 fallback 装全部。 + return &SyncResult{ + Action: "failed", + Err: fmt.Errorf("skills suite install failed: %s", resultDetail(installResult)), + Detail: resultDetail(installResult), + Force: opts.Force, + Suite: effectiveSuite, + } } + return fallbackFullInstall(opts, resultDetail(installResult), official) } state := SkillsState{ Version: opts.Version, OfficialSkills: plan.OfficialSkills, - UpdatedSkills: plan.ToUpdate, + UpdatedSkills: toInstall, AddedOfficialSkills: plan.Added, SkippedDeletedSkills: plan.SkippedDeleted, + SuiteSkills: suiteSkillsForState(suiteActive, effectiveSuite), UpdatedAt: opts.Now().UTC().Format(time.RFC3339), } if err := WriteState(state); err != nil { diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go index fb8f117fc..b7f110583 100644 --- a/internal/skillscheck/sync_test.go +++ b/internal/skillscheck/sync_test.go @@ -853,3 +853,190 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) { t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll) } } + +func TestParseSuiteSelection(t *testing.T) { + tests := []struct { + name string + input []string + wantAll bool + wantList []string + wantErr string // substring; "" means no error + }{ + {name: "explicit list", input: []string{"lark-calendar", "lark-im"}, wantList: []string{"lark-calendar", "lark-im"}}, + {name: "trim and dedup and sort", input: []string{" lark-im ", "lark-im", "lark-calendar"}, wantList: []string{"lark-calendar", "lark-im"}}, + {name: "all keyword", input: []string{"all"}, wantAll: true}, + {name: "all case insensitive", input: []string{"ALL"}, wantAll: true}, + {name: "all mixed", input: []string{"all", "lark-im"}, wantErr: "cannot be combined"}, + {name: "empty", input: []string{"", " "}, wantErr: "at least one"}, + {name: "invalid name", input: []string{"bad name"}, wantErr: "invalid skill name"}, + {name: "invalid name explains charset", input: []string{"bad name"}, wantErr: "letters, digits, and _ : -"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSuiteSelection(tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("ParseSuiteSelection(%v) err = %v, want substring %q", tt.input, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ParseSuiteSelection(%v) err = %v, want nil", tt.input, err) + } + if got.All != tt.wantAll { + t.Fatalf("All = %v, want %v", got.All, tt.wantAll) + } + if !tt.wantAll && !reflect.DeepEqual(got.Skills, tt.wantList) { + t.Fatalf("Skills = %#v, want %#v", got.Skills, tt.wantList) + } + }) + } +} + +func TestSyncSkills_SuiteNarrowsAndPersists(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im", "lark-doc"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-im", "lark-doc"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Runner: runner, + Now: time.Now, + Suite: &SuiteSelection{Skills: []string{"lark-calendar", "lark-im"}}, + }) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + // Only suite skills installed, never the full set. + assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-im"}) + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0 (suite must not full-install)", runner.installedAll) + } + assertStrings(t, result.Suite, []string{"lark-calendar", "lark-im"}) + + st, ok, err := ReadState() + if err != nil || !ok { + t.Fatalf("ReadState() = (_, %v, %v), want readable", ok, err) + } + assertStrings(t, st.SuiteSkills, []string{"lark-calendar", "lark-im"}) + assertStrings(t, st.OfficialSkills, []string{"lark-calendar", "lark-im"}) +} + +func TestSyncSkills_StickySuiteWhenFlagAbsent(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + // Previous run set a sticky suite. + if err := WriteState(SkillsState{ + Version: "1.0.32", + OfficialSkills: []string{"lark-calendar"}, + SuiteSkills: []string{"lark-calendar"}, + UpdatedAt: "2026-06-26T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im", "lark-doc"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar"), + } + // No Suite in opts → must reuse sticky {lark-calendar}, NOT install all. + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v", result.Err) + } + assertStrings(t, result.Suite, []string{"lark-calendar"}) + assertStrings(t, suiteState(t).SuiteSkills, []string{"lark-calendar"}) + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0", runner.installedAll) + } + for _, batch := range runner.installed { + for _, name := range batch { + if name != "lark-calendar" { + t.Fatalf("installed %q, want only lark-calendar (sticky suite)", name) + } + } + } +} + +func TestSyncSkills_AllResetsSuite(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + if err := WriteState(SkillsState{ + Version: "1.0.32", + SuiteSkills: []string{"lark-calendar"}, + UpdatedAt: "2026-06-26T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-im"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", Runner: runner, Now: time.Now, + Suite: &SuiteSelection{All: true}, + }) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v", result.Err) + } + if len(result.Suite) != 0 { + t.Fatalf("result.Suite = %#v, want empty after --skills all", result.Suite) + } + if got := suiteState(t).SuiteSkills; len(got) != 0 { + t.Fatalf("state.SuiteSkills = %#v, want cleared after --skills all", got) + } +} + +func TestSyncSkills_UnknownSuiteNameFailsWithoutInstall(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-im"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", Runner: runner, Now: time.Now, + Suite: &SuiteSelection{Skills: []string{"lark-bogus"}}, + }) + if result.Err == nil || !result.InvalidInput { + t.Fatalf("result = %#v, want InvalidInput error for unknown skill", result) + } + if !strings.Contains(result.Err.Error(), "lark-bogus") { + t.Fatalf("err = %v, want mention of lark-bogus", result.Err) + } + if len(runner.installed) != 0 || runner.installedAll != 0 { + t.Fatalf("installed=%v installedAll=%d, want nothing installed", runner.installed, runner.installedAll) + } +} + +func TestSyncSkills_SuiteNoFallbackWhenOfficialUnavailable(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexErr: fmt.Errorf("network down"), + officialErr: fmt.Errorf("network down"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", Runner: runner, Now: time.Now, + Suite: &SuiteSelection{Skills: []string{"lark-calendar"}}, + }) + if result.Err == nil { + t.Fatalf("result.Err = nil, want error when official list unavailable in suite mode") + } + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0 (suite must never fall back to full install)", runner.installedAll) + } +} + +// suiteState is a small helper to read state in assertions. +func suiteState(t *testing.T) *SkillsState { + t.Helper() + s, ok, err := ReadState() + if err != nil || !ok { + t.Fatalf("ReadState() = (_, %v, %v), want readable", ok, err) + } + return s +} diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 8b4813ecc..61976690d 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -121,6 +121,19 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_ **规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。 +### 自定义安装部分 skills(suite 模式) + +默认 `lark-cli update` 会同步全部官方 skills。如果只想安装其中一部分,用 `--skills` 指定(逗号分隔): + +```bash +lark-cli update --skills lark-calendar,lark-im # 只安装/同步这些 skill +``` + +- 选择会被**记住**:之后直接跑 `lark-cli update` 只同步这个子集,不会装回全部。 +- 恢复全部:`lark-cli update --skills all`。 +- 只同步、**不卸载**:设置 suite 不会删除本地已安装的其他 skill。 +- 名字非法或不是官方 skill 时命令以退出码 `2` 失败,不安装任何东西。 + ## 安全规则 - **禁止输出密钥**(appSecret、accessToken)到终端明文。