feat: add incremental skills sync (#965)

* feat: add incremental skills sync

* fix: address skills sync review feedback
This commit is contained in:
zhangheng023
2026-05-20 16:27:07 +08:00
committed by GitHub
parent 8c73f49e91
commit 3a3fc31d0b
15 changed files with 1193 additions and 438 deletions

View File

@@ -84,6 +84,7 @@ type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsUpdateOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -166,7 +167,46 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
return r
}
func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) InstallSkill(name string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", name)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", name)
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
}
func (u *Updater) runSkillsListGlobal() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g")
}
func (u *Updater) runSkillsInstall(source string, name string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-s", name, "-g", "-y")
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -175,7 +215,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
@@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
tests := []struct {
name string
run func(*Updater) *NpmResult
want string
}{
{
name: "list official primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsListOfficial("https://open.feishu.cn")
},
want: "-y skills add https://open.feishu.cn --list",
},
{
name: "list global",
run: func(u *Updater) *NpmResult {
return u.runSkillsListGlobal()
},
want: "-y skills ls -g",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsInstall("https://open.feishu.cn", "lark-mail")
},
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
script := filepath.Join(dir, "npx")
logPath := filepath.Join(dir, "npx.log")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
result := tt.run(New())
if result.Err != nil {
t.Fatalf("command err = %v, want nil", result.Err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(raw)) != tt.want {
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
}
})
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
r.Err = fmt.Errorf("primary failed")
return r
}
r.Stdout.WriteString("lark-calendar\n")
return r
},
}
result := updater.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
}
if !strings.Contains(called[1], "larksuite/cli --list") {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}

View File

@@ -3,46 +3,29 @@
package skillscheck
// Init runs the synchronous skills version check. Stores a StaleNotice
// when the local stamp records a version that does not match
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
// zero network, zero subprocess — only a local stamp file read.
import "strings"
// Init runs the synchronous skills version check. Stores a StaleNotice when
// the local skills state records a version that does not match currentVersion.
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local state file read.
//
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
//
// Failure modes (all → no notice, no nag):
// - shouldSkip rule met
// - ReadStamp returns an I/O error other than ENOENT
// - Stamp matches currentVersion (in-sync)
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
// opt into drift tracking; npx-only installs are intentionally silent.
func Init(currentVersion string) {
// Clear any stale notice from a prior call so early returns below
// (skip rules / read errors / cold start / in-sync) leave pending == nil
// instead of preserving a stale value from a previous Init invocation.
SetPending(nil)
if shouldSkip(currentVersion) {
return
}
stamp, err := ReadStamp()
if err != nil {
// Fail closed — don't nag for a transient FS problem.
version, ok := ReadSyncedVersion()
if !ok {
return
}
if stamp == "" {
// Cold start: the stamp is written exclusively by `lark-cli update`
// (runSkillsAndStamp). Users who installed skills via
// `npx skills add larksuite/cli -g` have no stamp yet — they must
// not be nagged with "skills not installed", since the on-disk
// skills directory may already be fully populated.
return
}
if stamp == currentVersion {
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
return
}
SetPending(&StaleNotice{
Current: stamp, // guaranteed non-empty under the new contract
Current: version,
Target: currentVersion,
})
}

View File

@@ -18,9 +18,8 @@ func resetPending(t *testing.T) {
func TestInit_InSync_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
}
}
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("v1.0.21")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
}
}
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// Even with an empty config dir (no stamp), DEV version should skip
// the check entirely and never emit a notice.
Init("DEV")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
}
}
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Make the stamp path a directory so vfs.ReadFile returns a
// non-ENOENT I/O error.
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
t.Fatal(err)
}
Init("1.0.21")

View File

@@ -3,9 +3,8 @@
// Package skillscheck verifies that the locally installed lark-cli
// skills are in sync with the running binary version, by comparing
// the current binary version against a stamp file written when skills
// are last synced (by `lark-cli update`). On mismatch it stores a
// notice for injection into JSON envelopes via output.PendingNotice.
// the current binary version against skills-state.json. On mismatch it
// stores a notice for injection into JSON envelopes via output.PendingNotice.
package skillscheck
import (
@@ -26,8 +25,7 @@ type StaleNotice struct {
// Message returns a single-line, AI-agent-parseable description of the
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
// non-empty because Init only emits a StaleNotice for the drift case
// (stamp present and != binary version).
// non-empty because Init only emits a StaleNotice for the drift case.
func (s *StaleNotice) Message() string {
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const stampFile = "skills.stamp"
// stampPath returns ~/.lark-cli/skills.stamp.
// Uses the BASE config dir (not workspace-aware) because skills install
// globally via `npx -g`; per-workspace tracking would produce false
// drift signals when switching workspaces.
func stampPath() string {
return filepath.Join(core.GetBaseConfigDir(), stampFile)
}
// ReadStamp returns the version recorded in the stamp file. Returns
// ("", nil) when the file does not exist (interpreted as "never synced").
// Other I/O errors are returned as-is so callers can fail closed.
func ReadStamp() (string, error) {
data, err := vfs.ReadFile(stampPath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// WriteStamp records `version` as the last successfully synced skills
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
// the base config directory if it does not exist.
func WriteStamp(version string) error {
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"path/filepath"
"testing"
)
func TestReadStamp_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := ReadStamp()
if err != nil {
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
}
if got != "" {
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
}
}
func TestReadStamp_Normal(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "1.0.21" {
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
}
}
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
}
}
func TestReadStamp_EmptyFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "" {
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
}
}
func TestWriteStamp_CreatesDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatalf("WriteStamp() = %v, want nil", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(got) != "1.0.21" {
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
}
}
func TestWriteStamp_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
}
}
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(raw) != "1.0.21" {
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
}
}
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
// when the base config dir cannot be created (parent path is a regular file).
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
tmp := t.TempDir()
blocker := filepath.Join(tmp, "blocker")
// Create a regular file where MkdirAll wants to create a directory.
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
if err := WriteStamp("1.0.21"); err == nil {
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"errors"
"io/fs"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
stateFile = "skills-state.json"
stateSchemaVersion = 1
)
type SkillsState struct {
SchemaVersion int `json:"schema_version"`
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedSkills []string `json:"added_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
}
func statePath() string {
return filepath.Join(core.GetBaseConfigDir(), stateFile)
}
func ReadState() (*SkillsState, bool, error) {
data, err := vfs.ReadFile(statePath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
return nil, false, err
}
var state SkillsState
if json.Unmarshal(data, &state) != nil {
state = SkillsState{}
}
if state.SchemaVersion != stateSchemaVersion {
return nil, false, nil
}
return &state, true, nil
}
func WriteState(state SkillsState) error {
state.SchemaVersion = stateSchemaVersion
state.ensureNonNilSlices()
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
}
func ReadSyncedVersion() (string, bool) {
state, ok, err := ReadState()
if err != nil || !ok || state.Version == "" {
return "", false
}
return state.Version, true
}
func (s *SkillsState) ensureNonNilSlices() {
if s.OfficialSkills == nil {
s.OfficialSkills = []string{}
}
if s.UpdatedSkills == nil {
s.UpdatedSkills = []string{}
}
if s.AddedSkills == nil {
s.AddedSkills = []string{}
}
if s.SkippedDeletedSkills == nil {
s.SkippedDeletedSkills = []string{}
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestReadState_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
state, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false for missing file")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
}
}
func TestReadState_Valid(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
want := SkillsState{
SchemaVersion: 1,
Version: "1.2.3",
OfficialSkills: []string{"lark-doc", "lark-im"},
UpdatedSkills: []string{"lark-doc"},
AddedSkills: []string{"lark-task"},
SkippedDeletedSkills: []string{"custom-skill"},
UpdatedAt: "2026-05-18T10:00:00Z",
}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
t.Fatal(err)
}
got, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil", err)
}
if !ok {
t.Fatal("ReadState() ok = false, want true")
}
if got == nil {
t.Fatal("ReadState() state = nil, want state")
}
if !reflect.DeepEqual(*got, want) {
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
}
}
func TestReadState_CorruptOrUnknownSchemaUnreadable(t *testing.T) {
tests := []struct {
name string
data []byte
}{
{name: "corrupt json", data: []byte(`{"schema_version":`)},
{name: "unknown schema", data: []byte(`{"schema_version":2,"version":"1.2.3"}`)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, stateFile), tt.data, 0o644); err != nil {
t.Fatal(err)
}
state, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil", state)
}
})
}
}
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
state := SkillsState{
Version: "1.2.3",
UpdatedAt: "2026-05-18T10:00:00Z",
}
if err := WriteState(state); err != nil {
t.Fatalf("WriteState() err = %v, want nil", err)
}
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
if err != nil {
t.Fatal(err)
}
var got SkillsState
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("written state is invalid JSON: %v", err)
}
if got.SchemaVersion != 1 {
t.Fatalf("schema_version = %d, want 1", got.SchemaVersion)
}
if got.Version != state.Version {
t.Fatalf("version = %q, want %q", got.Version, state.Version)
}
if got.OfficialSkills == nil {
t.Fatal("official_skills decoded as nil, want empty slice")
}
if got.UpdatedSkills == nil {
t.Fatal("updated_skills decoded as nil, want empty slice")
}
if got.AddedSkills == nil {
t.Fatal("added_skills decoded as nil, want empty slice")
}
if got.SkippedDeletedSkills == nil {
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
}
}
func TestReadSyncedVersionFromState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
}
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
}
if err := WriteState(SkillsState{}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
}
}

View File

@@ -0,0 +1,265 @@
// 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
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
func TestParseSkillsList(t *testing.T) {
input := `Installed skills:
- lark-calendar
- lark-mail
lark-im
custom-skill
lark-base@1.0.0
lark-cli-harness:dev@0.1.0
`
got := ParseSkillsList(input)
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-cli-harness:dev", "lark-im", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() = %#v, want %#v", got, want)
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar", "lark-custom"},
PreviousState: previous,
StateReadable: true,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
assertStrings(t, got.Added, []string{"lark-new"})
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
}
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
StateReadable: false,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.SkippedDeleted, []string{})
}
func TestPlanForceRestoresAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
StateReadable: true,
Force: true,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{})
assertStrings(t, got.SkippedDeleted, []string{})
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr map[string]error
installed []string
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
return r
}
func (f *fakeSkillsRunner) InstallSkill(name string) *selfupdate.NpmResult {
f.installed = append(f.installed, name)
r := &selfupdate.NpmResult{}
if f.installErr != nil {
r.Err = f.installErr[name]
}
return r
}
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialOut: "lark-calendar\nlark-mail\nlark-new\n",
globalOut: "lark-calendar\nlark-custom\n",
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-new"})
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
assertStrings(t, state.AddedSkills, []string{"lark-new"})
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
}
}
func TestSyncSkills_ListFailureDoesNotInstallOrWriteState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{officialErr: fmt.Errorf("list failed")}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list official skills") {
t.Fatalf("SyncSkills() err = %v, want official list failure", result.Err)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want none", runner.installed)
}
if _, readable, err := ReadState(); err != nil || readable {
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
}
}
func TestSyncSkills_GlobalListFailureDoesNotInstallOrWriteState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "lark-calendar\nlark-mail\n",
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to list installed skills") {
t.Fatalf("SyncSkills() err = %v, want installed list failure", result.Err)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want none", runner.installed)
}
if _, readable, err := ReadState(); err != nil || readable {
t.Fatalf("ReadState() = (_, %v, %v), want unreadable missing state", readable, err)
}
}
func TestSyncSkills_InstallFailureContinuesAndDoesNotWriteState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "lark-calendar\nlark-mail\n",
globalOut: "lark-calendar\nlark-mail\n",
installErr: map[string]error{"lark-calendar": fmt.Errorf("boom")},
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "1 skill(s) failed") {
t.Fatalf("SyncSkills() err = %v, want install failure", result.Err)
}
assertStrings(t, runner.installed, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Failed, []string{"lark-calendar"})
if !strings.Contains(result.Detail, "boom") {
t.Fatalf("SyncSkills() detail = %q, want install error text", result.Detail)
}
if _, readable, err := ReadState(); err != nil || readable {
t.Fatalf("ReadState() = (_, %v, %v), want no success state", readable, err)
}
}
func TestSyncSkills_NilRunnerFails(t *testing.T) {
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}