mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience. Changes: - Add official skills index JSON parsing in `internal/skillscheck/sync.go` - Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go` - Add reason-chain details when both discovery layers fall back to `fallbackFullInstall` - Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go` - Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go` Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
504 lines
12 KiB
Go
504 lines
12 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package skillscheck
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/selfupdate"
|
|
)
|
|
|
|
var (
|
|
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
|
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
|
)
|
|
|
|
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 stripANSI(s string) string {
|
|
return ansiPattern.ReplaceAllString(s, "")
|
|
}
|
|
|
|
func ParseSkillsList(text string) []string {
|
|
text = stripANSI(text)
|
|
lines := strings.Split(text, "\n")
|
|
|
|
// Detect format type
|
|
hasGlobalSkills := strings.Contains(text, "Global Skills")
|
|
hasAvailableSkills := strings.Contains(text, "Available Skills")
|
|
|
|
if hasGlobalSkills {
|
|
// Format 1: locally installed skills list from "npx -y skills ls -g"
|
|
return parseGlobalSkillsList(lines)
|
|
} else if hasAvailableSkills {
|
|
// Format 2: official skills list from "npx -y skills add ... --list"
|
|
return parseOfficialSkillsList(lines)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ParseGlobalSkillsJSON(text string) []string {
|
|
type globalSkill struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
var skills []globalSkill
|
|
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
|
return nil
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
for _, skill := range skills {
|
|
candidate := strings.TrimSpace(skill.Name)
|
|
if candidate == "" || !skillNamePattern.MatchString(candidate) {
|
|
continue
|
|
}
|
|
seen[candidate] = true
|
|
}
|
|
|
|
return sortedKeys(seen)
|
|
}
|
|
|
|
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
|
|
type officialSkill struct {
|
|
Name string `json:"name"`
|
|
}
|
|
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) {
|
|
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
|
|
}
|
|
}
|
|
|
|
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
|
|
func parseOfficialSkillsList(lines []string) []string {
|
|
seen := map[string]bool{}
|
|
inAvailableSection := false
|
|
|
|
for _, line := range lines {
|
|
// Check if we've reached the "Available Skills" section
|
|
if strings.Contains(line, "Available Skills") {
|
|
inAvailableSection = true
|
|
continue
|
|
}
|
|
|
|
if !inAvailableSection {
|
|
continue
|
|
}
|
|
|
|
// Process lines containing "│", e.g. " │ lark-approval "
|
|
if strings.Contains(line, "│") {
|
|
// Remove all "│" characters and spaces, extract the first valid token in order
|
|
parts := strings.FieldsFunc(line, func(r rune) bool {
|
|
return r == '│' || r == ' '
|
|
})
|
|
|
|
if len(parts) > 0 {
|
|
candidate := parts[0]
|
|
if skillNamePattern.MatchString(candidate) {
|
|
seen[candidate] = 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)
|
|
installedOfficial := intersection(input.LocalSkills, officialSet)
|
|
|
|
previousOfficial := []string{}
|
|
if input.StateReadable && input.PreviousState != nil {
|
|
previousOfficial = input.PreviousState.OfficialSkills
|
|
}
|
|
previousSet := toSet(previousOfficial)
|
|
|
|
newAddedOfficial := []string{}
|
|
for _, skill := range official {
|
|
if !previousSet[skill] {
|
|
newAddedOfficial = append(newAddedOfficial, skill)
|
|
}
|
|
}
|
|
|
|
updateSet := toSet(installedOfficial)
|
|
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,
|
|
ToUpdate: toUpdate,
|
|
Added: uniqueSorted(newAddedOfficial),
|
|
SkippedDeleted: skipped,
|
|
}
|
|
}
|
|
|
|
type SkillsRunner interface {
|
|
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
|
ListOfficialSkills() *selfupdate.NpmResult
|
|
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
|
ListGlobalSkills() *selfupdate.NpmResult
|
|
InstallSkill(nameList []string) *selfupdate.NpmResult
|
|
InstallAllSkills() *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")}
|
|
}
|
|
|
|
// --- 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, readable, err := ReadState()
|
|
if err != nil {
|
|
readable = false
|
|
previous = nil
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
if len(plan.ToUpdate) == 0 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
state := SkillsState{
|
|
Version: opts.Version,
|
|
OfficialSkills: plan.OfficialSkills,
|
|
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
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
|
reasons := []string{}
|
|
|
|
indexResult := runner.ListOfficialSkillsIndex()
|
|
if indexResult == nil || indexResult.Err != nil {
|
|
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
|
|
} else {
|
|
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
|
|
if err != nil {
|
|
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
|
|
} else if len(official) > 0 {
|
|
return official, "", true
|
|
} else {
|
|
reasons = append(reasons, "official skills index contains no skills")
|
|
}
|
|
}
|
|
|
|
officialResult := runner.ListOfficialSkills()
|
|
if officialResult == nil || officialResult.Err != nil {
|
|
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
|
|
return nil, strings.Join(reasons, "; "), false
|
|
}
|
|
official := ParseSkillsList(officialResult.Stdout.String())
|
|
if len(official) > 0 {
|
|
return official, "", true
|
|
}
|
|
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
|
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
|
|
} else {
|
|
reasons = append(reasons, "official skills list returned no skills")
|
|
}
|
|
return nil, strings.Join(reasons, "; "), false
|
|
}
|
|
|
|
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
|
jsonResult := runner.ListGlobalSkillsJSON()
|
|
if jsonResult != nil && jsonResult.Err == nil {
|
|
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
|
return local, true
|
|
}
|
|
}
|
|
|
|
textResult := runner.ListGlobalSkills()
|
|
if textResult != nil && textResult.Err == nil {
|
|
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
|
|
return local, true
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
|
|
// when incremental sync is not possible. On success it writes a state file so that
|
|
// subsequent syncs can use incremental mode. When official is non-nil the state
|
|
// records the full official list; otherwise a minimal state (version only) is
|
|
// written to break the fallback loop.
|
|
func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult {
|
|
installResult := opts.Runner.InstallAllSkills()
|
|
if installResult == nil {
|
|
return &SyncResult{
|
|
Action: "fallback_failed",
|
|
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
|
Detail: reason,
|
|
Force: opts.Force,
|
|
}
|
|
}
|
|
if installResult.Err != nil {
|
|
return &SyncResult{
|
|
Action: "fallback_failed",
|
|
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
|
Detail: reason + "\n" + resultDetail(installResult),
|
|
Force: opts.Force,
|
|
}
|
|
}
|
|
|
|
state := SkillsState{
|
|
Version: opts.Version,
|
|
OfficialSkills: official,
|
|
UpdatedSkills: official,
|
|
AddedOfficialSkills: official,
|
|
SkippedDeletedSkills: []string{},
|
|
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
if writeErr := WriteState(state); writeErr != nil {
|
|
return &SyncResult{
|
|
Action: "fallback_synced",
|
|
Official: official,
|
|
Updated: official,
|
|
Added: official,
|
|
SkippedDeleted: []string{},
|
|
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
|
Force: opts.Force,
|
|
}
|
|
}
|
|
|
|
return &SyncResult{
|
|
Action: "fallback_synced",
|
|
Official: official,
|
|
Updated: official,
|
|
Added: official,
|
|
SkippedDeleted: []string{},
|
|
Detail: reason,
|
|
Force: opts.Force,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|