mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
575 lines
15 KiB
Go
575 lines
15 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package skillscheck
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/selfupdate"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
var (
|
|
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
|
digestPattern = regexp.MustCompile(`^sha256:[0-9a-fA-F]{64}$`)
|
|
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
|
)
|
|
|
|
const githubSkillsSource = "larksuite/cli"
|
|
|
|
type SyncInput struct {
|
|
Version string
|
|
OfficialSkills []string
|
|
LocalSkills []string
|
|
PreviousState *SkillsState
|
|
StateReadable bool
|
|
Force bool
|
|
}
|
|
|
|
type SyncPlan struct {
|
|
Version string
|
|
OfficialSkills []string
|
|
CleanupOfficial []string
|
|
ToUpdate []string
|
|
Added []string
|
|
SkippedDeleted []string
|
|
}
|
|
|
|
func stripANSI(s string) string {
|
|
return ansiPattern.ReplaceAllString(s, "")
|
|
}
|
|
|
|
func ParseSkillsList(text string) []string {
|
|
text = stripANSI(text)
|
|
if strings.Contains(text, "Global Skills") {
|
|
return parseGlobalSkillsList(strings.Split(text, "\n"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type installedSkill struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
func parseInstalledSkillsJSON(text string) ([]installedSkill, error) {
|
|
type globalSkill struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
var skills []globalSkill
|
|
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
seen := map[string]installedSkill{}
|
|
for _, skill := range skills {
|
|
candidate := strings.TrimSpace(skill.Name)
|
|
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
|
continue
|
|
}
|
|
seen[candidate] = installedSkill{Name: candidate, Path: strings.TrimSpace(skill.Path)}
|
|
}
|
|
|
|
names := make([]string, 0, len(seen))
|
|
for name := range seen {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
entries := make([]installedSkill, 0, len(names))
|
|
for _, name := range names {
|
|
entries = append(entries, seen[name])
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
|
|
type officialSkill struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
URL string `json:"url"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
type officialIndex struct {
|
|
Skills []officialSkill `json:"skills"`
|
|
}
|
|
|
|
var index officialIndex
|
|
if err := json.Unmarshal([]byte(text), &index); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, skill := range index.Skills {
|
|
candidate := strings.TrimSpace(skill.Name)
|
|
if !skillNamePattern.MatchString(candidate) {
|
|
return nil, fmt.Errorf("invalid skill name %q", candidate)
|
|
}
|
|
if skill.Type != "archive" || strings.TrimSpace(skill.URL) == "" || !digestPattern.MatchString(skill.Digest) {
|
|
return nil, fmt.Errorf("skill %s is not a complete v0.2 archive entry", candidate)
|
|
}
|
|
if seen[candidate] {
|
|
return nil, fmt.Errorf("duplicate skill %s", candidate)
|
|
}
|
|
seen[candidate] = true
|
|
}
|
|
|
|
return sortedKeys(seen), nil
|
|
}
|
|
|
|
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
|
func parseGlobalSkillsList(lines []string) []string {
|
|
seen := map[string]bool{}
|
|
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Skip header
|
|
if strings.HasPrefix(trimmed, "Global Skills") {
|
|
continue
|
|
}
|
|
|
|
// Skip empty lines
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "Tip:") {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(trimmed, "Agents:") {
|
|
continue
|
|
}
|
|
|
|
if isGlobalSkillsSectionHeader(trimmed) {
|
|
continue
|
|
}
|
|
|
|
// Extract skill name, format is typically "skill-name /path/to/skill"
|
|
parts := strings.Fields(trimmed)
|
|
if len(parts) == 0 {
|
|
continue
|
|
}
|
|
|
|
candidate := parts[0]
|
|
|
|
// Validate and add
|
|
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
|
continue
|
|
}
|
|
seen[candidate] = true
|
|
}
|
|
|
|
return sortedKeys(seen)
|
|
}
|
|
|
|
func isGlobalSkillsSectionHeader(line string) bool {
|
|
switch line {
|
|
case "General", "Project", "Local":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func PlanSync(input SyncInput) SyncPlan {
|
|
official := uniqueSorted(input.OfficialSkills)
|
|
previousOfficial := []string{}
|
|
if input.StateReadable && input.PreviousState != nil {
|
|
previousOfficial = input.PreviousState.OfficialSkills
|
|
}
|
|
cleanupOfficial := uniqueSorted(append(append([]string{}, official...), previousOfficial...))
|
|
if input.Force {
|
|
return SyncPlan{
|
|
Version: input.Version,
|
|
OfficialSkills: official,
|
|
CleanupOfficial: cleanupOfficial,
|
|
ToUpdate: official,
|
|
Added: []string{},
|
|
SkippedDeleted: []string{},
|
|
}
|
|
}
|
|
|
|
officialSet := toSet(official)
|
|
installedOfficial := intersection(input.LocalSkills, officialSet)
|
|
|
|
previousSet := toSet(previousOfficial)
|
|
|
|
newAddedOfficial := []string{}
|
|
for _, skill := range official {
|
|
if !previousSet[skill] {
|
|
newAddedOfficial = append(newAddedOfficial, skill)
|
|
}
|
|
}
|
|
|
|
updateSet := toSet(installedOfficial)
|
|
if len(installedOfficial) == 0 {
|
|
updateSet = toSet(official)
|
|
}
|
|
for _, skill := range newAddedOfficial {
|
|
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,
|
|
CleanupOfficial: cleanupOfficial,
|
|
ToUpdate: toUpdate,
|
|
Added: uniqueSorted(newAddedOfficial),
|
|
SkippedDeleted: skipped,
|
|
}
|
|
}
|
|
|
|
type SkillsRunner interface {
|
|
SkillsSources() []string
|
|
FetchSkillsIndex(source string) *selfupdate.NpmResult
|
|
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
|
ListGlobalSkills() *selfupdate.NpmResult
|
|
InstallSkills(source string, nameList []string) *selfupdate.NpmResult
|
|
InstallAllSkills(source string) *selfupdate.NpmResult
|
|
StageSuite(source, dir string) *selfupdate.NpmResult
|
|
InstallLocalSuite(path string) *selfupdate.NpmResult
|
|
RemoveGlobalSkills(names []string) *selfupdate.NpmResult
|
|
}
|
|
|
|
type SyncOptions struct {
|
|
Version string
|
|
Layout Layout
|
|
Force bool
|
|
Runner SkillsRunner
|
|
Now func() time.Time
|
|
}
|
|
|
|
type SyncResult struct {
|
|
Action string
|
|
Official []string
|
|
OfficialUnknown bool
|
|
Updated []string
|
|
Added []string
|
|
SkippedDeleted []string
|
|
Failed []string
|
|
Err error
|
|
Detail string
|
|
Warning string
|
|
Layout Layout
|
|
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")}
|
|
}
|
|
|
|
previous, readable, err := ReadState()
|
|
if err != nil {
|
|
readable = false
|
|
previous = nil
|
|
}
|
|
targetLayout, err := ResolveLayout(opts.Layout, previous, readable)
|
|
if err != nil {
|
|
return &SyncResult{Action: "failed", Err: err}
|
|
}
|
|
installed, err := listInstalledSkills(opts.Runner)
|
|
if err != nil {
|
|
return &SyncResult{Action: "failed", Layout: targetLayout, Err: err}
|
|
}
|
|
localOfficial, err := localOfficialSkills(installed, previous, readable)
|
|
if err != nil {
|
|
// A suite whose installed path or references cannot be read is treated as
|
|
// absent. Planning from the old state would otherwise produce no updates
|
|
// and leave the damaged installation in place.
|
|
localOfficial = nil
|
|
readable = false
|
|
previous = nil
|
|
} else if readable && previous != nil && previous.OfficialSkillsUnknown {
|
|
// A cold GitHub fallback installed content without a trustworthy official
|
|
// list. Retry the official sources as a cold sync, even at the same version.
|
|
readable = false
|
|
previous = nil
|
|
}
|
|
|
|
reasons := []string{}
|
|
var fallbackPlan *SyncPlan
|
|
for _, source := range opts.Runner.SkillsSources() {
|
|
official, fetchErr := fetchOfficialSkills(opts.Runner, source)
|
|
if fetchErr != nil {
|
|
reasons = append(reasons, source+": "+fetchErr.Error())
|
|
continue
|
|
}
|
|
plan := PlanSync(SyncInput{
|
|
Version: opts.Version,
|
|
OfficialSkills: official,
|
|
LocalSkills: localOfficial,
|
|
PreviousState: previous,
|
|
StateReadable: readable,
|
|
Force: opts.Force,
|
|
})
|
|
fallbackPlan = &plan
|
|
|
|
if syncErr := syncLayout(opts.Runner, source, targetLayout, plan, installed); syncErr != nil {
|
|
reasons = append(reasons, source+": "+syncErr.Error())
|
|
continue
|
|
}
|
|
return finishSync(opts, targetLayout, plan, "", "", false)
|
|
}
|
|
|
|
if targetLayout == LayoutSuite {
|
|
return &SyncResult{
|
|
Action: "failed",
|
|
Layout: targetLayout,
|
|
Err: fmt.Errorf("suite skills sync failed: %s", strings.Join(reasons, "; ")),
|
|
Detail: strings.Join(reasons, "\n"),
|
|
Force: opts.Force,
|
|
}
|
|
}
|
|
|
|
return fallbackSeparate(opts, previous, readable, localOfficial, installed, fallbackPlan, reasons)
|
|
}
|
|
|
|
func fetchOfficialSkills(runner SkillsRunner, source string) ([]string, error) {
|
|
result := runner.FetchSkillsIndex(source)
|
|
if result == nil || result.Err != nil {
|
|
return nil, fmt.Errorf("index request failed: %s", resultDetail(result))
|
|
}
|
|
official, err := ParseOfficialSkillsIndexJSON(result.Stdout.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid v0.2 index: %w", err)
|
|
}
|
|
if len(official) == 0 {
|
|
return nil, fmt.Errorf("v0.2 index contains no skills")
|
|
}
|
|
return official, nil
|
|
}
|
|
|
|
func listInstalledSkills(runner SkillsRunner) ([]installedSkill, error) {
|
|
jsonResult := runner.ListGlobalSkillsJSON()
|
|
if jsonResult != nil && jsonResult.Err == nil {
|
|
if installed, err := parseInstalledSkillsJSON(jsonResult.Stdout.String()); err == nil {
|
|
return installed, nil
|
|
}
|
|
}
|
|
|
|
textResult := runner.ListGlobalSkills()
|
|
if textResult != nil && textResult.Err == nil {
|
|
names := ParseSkillsList(textResult.Stdout.String())
|
|
if names != nil {
|
|
installed := make([]installedSkill, 0, len(names))
|
|
for _, name := range names {
|
|
installed = append(installed, installedSkill{Name: name})
|
|
}
|
|
return installed, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("local skills list failed")
|
|
}
|
|
|
|
func localOfficialSkills(installed []installedSkill, previous *SkillsState, readable bool) ([]string, error) {
|
|
if !readable || previous == nil || EffectiveLayout(previous) == LayoutSeparate {
|
|
names := make([]string, 0, len(installed))
|
|
for _, skill := range installed {
|
|
names = append(names, skill.Name)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
for _, skill := range installed {
|
|
if skill.Name != "lark-suite" {
|
|
continue
|
|
}
|
|
if skill.Path == "" {
|
|
return nil, fmt.Errorf("cannot inspect installed lark-suite: global skills JSON did not include its path")
|
|
}
|
|
return listDirectSubdirs(filepath.Join(skill.Path, "references"))
|
|
}
|
|
return nil, fmt.Errorf("cannot inspect installed lark-suite: skill is not installed")
|
|
}
|
|
|
|
func listDirectSubdirs(root string) ([]string, error) {
|
|
entries, err := vfs.ReadDir(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := []string{}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
names = append(names, entry.Name())
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names, nil
|
|
}
|
|
|
|
func syncLayout(runner SkillsRunner, source string, layout Layout, plan SyncPlan, installed []installedSkill) error {
|
|
if layout == LayoutSuite {
|
|
return syncSuite(runner, source, plan, installed)
|
|
}
|
|
if len(plan.ToUpdate) > 0 {
|
|
result := runner.InstallSkills(source, plan.ToUpdate)
|
|
if result == nil || result.Err != nil {
|
|
return fmt.Errorf("archive install failed: %s", resultDetail(result))
|
|
}
|
|
}
|
|
if hasInstalledSkill(installed, "lark-suite") {
|
|
if result := runner.RemoveGlobalSkills([]string{"lark-suite"}); result == nil || result.Err != nil {
|
|
return fmt.Errorf("remove lark-suite failed: %s", resultDetail(result))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fallbackSeparate(opts SyncOptions, previous *SkillsState, readable bool, local []string, installed []installedSkill, plan *SyncPlan, reasons []string) *SyncResult {
|
|
if plan == nil && readable && previous != nil && len(previous.OfficialSkills) > 0 {
|
|
fallback := PlanSync(SyncInput{
|
|
Version: opts.Version,
|
|
OfficialSkills: previous.OfficialSkills,
|
|
LocalSkills: local,
|
|
PreviousState: previous,
|
|
StateReadable: true,
|
|
Force: opts.Force,
|
|
})
|
|
plan = &fallback
|
|
}
|
|
|
|
var installResult *selfupdate.NpmResult
|
|
officialUnknown := plan == nil
|
|
if plan == nil {
|
|
installResult = opts.Runner.InstallAllSkills(githubSkillsSource)
|
|
} else if len(plan.ToUpdate) > 0 {
|
|
installResult = opts.Runner.InstallSkills(githubSkillsSource, plan.ToUpdate)
|
|
}
|
|
if installResult != nil && installResult.Err != nil {
|
|
reasons = append(reasons, githubSkillsSource+": "+resultDetail(installResult))
|
|
return &SyncResult{
|
|
Action: "failed",
|
|
Layout: LayoutSeparate,
|
|
Err: fmt.Errorf("separate skills sync failed: %s", strings.Join(reasons, "; ")),
|
|
Detail: strings.Join(reasons, "\n"),
|
|
Force: opts.Force,
|
|
}
|
|
}
|
|
if hasInstalledSkill(installed, "lark-suite") {
|
|
if result := opts.Runner.RemoveGlobalSkills([]string{"lark-suite"}); result == nil || result.Err != nil {
|
|
return &SyncResult{Action: "failed", Layout: LayoutSeparate, Err: fmt.Errorf("remove lark-suite failed: %s", resultDetail(result)), Force: opts.Force}
|
|
}
|
|
}
|
|
if plan == nil {
|
|
empty := SyncPlan{Version: opts.Version}
|
|
plan = &empty
|
|
}
|
|
warning := ""
|
|
if installResult != nil {
|
|
warning = "used the GitHub legacy fallback; installed Skill content may be incomplete because the legacy protocol can ignore individual file download failures"
|
|
}
|
|
return finishSync(opts, LayoutSeparate, *plan, "fallback_synced", warning, officialUnknown)
|
|
}
|
|
|
|
func finishSync(opts SyncOptions, layout Layout, plan SyncPlan, action, warning string, officialUnknown bool) *SyncResult {
|
|
if action == "" {
|
|
action = "synced"
|
|
}
|
|
result := &SyncResult{
|
|
Action: action,
|
|
Official: plan.OfficialSkills,
|
|
OfficialUnknown: officialUnknown,
|
|
Updated: plan.ToUpdate,
|
|
Added: plan.Added,
|
|
SkippedDeleted: plan.SkippedDeleted,
|
|
Warning: warning,
|
|
Layout: layout,
|
|
Force: opts.Force,
|
|
}
|
|
state := SkillsState{
|
|
Version: opts.Version,
|
|
Layout: layout,
|
|
OfficialSkills: plan.OfficialSkills,
|
|
OfficialSkillsUnknown: officialUnknown,
|
|
UpdatedSkills: plan.ToUpdate,
|
|
AddedOfficialSkills: 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
|
|
}
|
|
|
|
func hasInstalledSkill(installed []installedSkill, name string) bool {
|
|
for _, skill := range installed {
|
|
if skill.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// result = { x | x ∈ values ∧ x ∈ allowed }
|
|
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
|
|
}
|