mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
codex/html
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eabb27272 | ||
|
|
45399ca70f | ||
|
|
9e2cac6b6c | ||
|
|
95ee561bf0 | ||
|
|
eab2063131 | ||
|
|
d592022b6e | ||
|
|
6d4f458683 |
@@ -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, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
// --- Step 3: Read previous state ---
|
||||
// 先读 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: 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)到终端明文。
|
||||
|
||||
Reference in New Issue
Block a user