Files
zhangheng023 3a3fc31d0b feat: add incremental skills sync (#965)
* feat: add incremental skills sync

* fix: address skills sync review feedback
2026-05-20 16:27:07 +08:00

266 lines
6.5 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
var skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
type SyncInput struct {
Version string
OfficialSkills []string
LocalSkills []string
PreviousState *SkillsState
StateReadable bool
Force bool
}
type SyncPlan struct {
Version string
OfficialSkills []string
ToUpdate []string
Added []string
SkippedDeleted []string
}
func ParseSkillsList(text string) []string {
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
token := strings.TrimSpace(line)
token = strings.TrimPrefix(token, "-")
token = strings.TrimSpace(token)
if token == "" || strings.Contains(token, " ") || strings.HasSuffix(token, ":") {
continue
}
if !skillNamePattern.MatchString(token) {
continue
}
if at := strings.Index(token, "@"); at > 0 {
token = token[:at]
}
seen[token] = true
}
return sortedKeys(seen)
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
if input.Force {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: official,
Added: []string{},
SkippedDeleted: []string{},
}
}
officialSet := toSet(official)
localOfficial := intersection(input.LocalSkills, officialSet)
previousOfficial := []string{}
if input.StateReadable && input.PreviousState != nil {
previousOfficial = input.PreviousState.OfficialSkills
}
previousSet := toSet(previousOfficial)
newOfficial := []string{}
for _, skill := range official {
if !previousSet[skill] {
newOfficial = append(newOfficial, skill)
}
}
updateSet := toSet(localOfficial)
for _, skill := range newOfficial {
updateSet[skill] = true
}
toUpdate := sortedKeys(updateSet)
updateSet = toSet(toUpdate)
skipped := []string{}
for _, skill := range official {
if !updateSet[skill] {
skipped = append(skipped, skill)
}
}
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: toUpdate,
Added: uniqueSorted(newOfficial),
SkippedDeleted: skipped,
}
}
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(name string) *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
Action string
Official []string
Updated []string
Added []string
SkippedDeleted []string
Failed []string
Err error
Detail string
Force bool
}
func SyncSkills(opts SyncOptions) *SyncResult {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Runner == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: empty result")}
}
if officialResult.Err != nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("failed to list official skills: %w", officialResult.Err), Detail: resultDetail(officialResult)}
}
official := ParseSkillsList(officialResult.Stdout.String())
localResult := opts.Runner.ListGlobalSkills()
if localResult == nil {
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: empty result")}
}
if localResult.Err != nil {
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to list installed skills: %w", localResult.Err), Detail: resultDetail(localResult)}
}
local := ParseSkillsList(localResult.Stdout.String())
previous, readable, err := ReadState()
if err != nil {
return &SyncResult{Action: "failed", Official: official, Err: fmt.Errorf("failed to read skills state: %w", err)}
}
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
result := &SyncResult{
Action: "synced",
Official: plan.OfficialSkills,
Updated: plan.ToUpdate,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
}
failed := []string{}
var details []string
for _, skill := range plan.ToUpdate {
installResult := opts.Runner.InstallSkill(skill)
if installResult == nil {
failed = append(failed, skill)
details = append(details, skill+": empty result")
continue
}
if installResult.Err != nil {
failed = append(failed, skill)
details = append(details, skill+": "+resultDetail(installResult))
}
}
if len(failed) > 0 {
result.Action = "failed"
result.Failed = failed
result.Err = fmt.Errorf("%d skill(s) failed", len(failed))
result.Detail = strings.Join(details, "\n")
return result
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
return result
}
return result
}
func resultDetail(result *selfupdate.NpmResult) string {
if result == nil {
return ""
}
parts := []string{}
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
parts = append(parts, output)
}
if result.Err != nil {
parts = append(parts, result.Err.Error())
}
return strings.Join(parts, "\n")
}
func uniqueSorted(values []string) []string {
return sortedKeys(toSet(values))
}
func toSet(values []string) map[string]bool {
out := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out[value] = true
}
}
return out
}
func intersection(values []string, allowed map[string]bool) []string {
out := map[string]bool{}
for _, value := range values {
if allowed[value] {
out[value] = true
}
}
return sortedKeys(out)
}
func sortedKeys(values map[string]bool) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}