chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
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).
|
||||
func Init(currentVersion string) {
|
||||
SetPending(nil)
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
version, ok := ReadSyncedVersion()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
|
||||
return
|
||||
}
|
||||
SetPending(&StaleNotice{
|
||||
Current: version,
|
||||
Target: currentVersion,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resetPending(t *testing.T) {
|
||||
t.Helper()
|
||||
SetPending(nil)
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
}
|
||||
|
||||
func TestInit_InSync_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
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")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (in-sync)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ColdStart_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
Init("1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (cold start is silent)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
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")
|
||||
got := GetPending()
|
||||
if got == nil {
|
||||
t.Fatal("GetPending() = nil, want non-nil for drift")
|
||||
}
|
||||
if got.Current != "1.0.20" || got.Target != "1.0.21" {
|
||||
t.Errorf("notice = %+v, want {Current:\"1.0.20\", Target:\"1.0.21\"}", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_Skipped_NoNotice(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
Init("DEV")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
resetPending(t)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Init("1.0.21")
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("GetPending() = %+v, want nil (fail closed on I/O error)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// 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 skills-state.json. On mismatch it
|
||||
// stores a notice for injection into JSON envelopes via output.PendingNotice.
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// StaleNotice signals that the locally synced skills version does not
|
||||
// match the running binary. Current is the last successfully synced
|
||||
// version (always non-empty — Init no longer emits a notice on cold
|
||||
// start). Target is the running binary version. Mirrors
|
||||
// internal/update.UpdateInfo's pending-notice pattern.
|
||||
type StaleNotice struct {
|
||||
Current string `json:"current"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *StaleNotice) Message() string {
|
||||
return fmt.Sprintf(
|
||||
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
|
||||
s.Current, s.Target,
|
||||
)
|
||||
}
|
||||
|
||||
// pending stores the latest stale notice for the current process.
|
||||
var pending atomic.Pointer[StaleNotice]
|
||||
|
||||
// SetPending stores the stale notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *StaleNotice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending stale notice, or nil.
|
||||
func GetPending() *StaleNotice { return pending.Load() }
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaleNotice_Message(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
n StaleNotice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"drift",
|
||||
StaleNotice{Current: "1.0.20", Target: "1.0.21"},
|
||||
"lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.n.Message(); got != tt.want {
|
||||
t.Errorf("Message() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
SetPending(nil)
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("initial GetPending() = %+v, want nil", got)
|
||||
}
|
||||
|
||||
want := &StaleNotice{Current: "1.0.20", Target: "1.0.21"}
|
||||
SetPending(want)
|
||||
got := GetPending()
|
||||
if got == nil || got.Current != "1.0.20" || got.Target != "1.0.21" {
|
||||
t.Errorf("GetPending() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending_Concurrent(t *testing.T) {
|
||||
SetPending(nil)
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
SetPending(&StaleNotice{Current: "a", Target: "b"})
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = GetPending()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// Just verifying no race; -race flag enforces.
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
// shouldSkip returns true when the skills check should be silently
|
||||
// suppressed. Mirrors internal/update.shouldSkip semantics but uses
|
||||
// a dedicated opt-out env var so users can disable the skills nag
|
||||
// without also disabling the binary update nag.
|
||||
func shouldSkip(version string) bool {
|
||||
if os.Getenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER") != "" {
|
||||
return true
|
||||
}
|
||||
if update.IsCIEnv() {
|
||||
return true
|
||||
}
|
||||
if version == "DEV" || version == "dev" || version == "" {
|
||||
return true
|
||||
}
|
||||
return !update.IsRelease(version)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clearSkillsSkipEnv unsets the env vars shouldSkip checks so the
|
||||
// host environment cannot pollute test results.
|
||||
func clearSkillsSkipEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
t.Setenv(key, "")
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"release_no_skip", clearSkillsSkipEnv, "1.0.21", false},
|
||||
{"dev_uppercase", clearSkillsSkipEnv, "DEV", true},
|
||||
{"dev_lowercase", clearSkillsSkipEnv, "dev", true},
|
||||
{"empty_version", clearSkillsSkipEnv, "", true},
|
||||
{"git_describe", clearSkillsSkipEnv, "1.0.0-12-g9b933f1-dirty", true},
|
||||
{"opt_out", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
}, "1.0.21", true},
|
||||
{"ci_env", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("CI", "true")
|
||||
}, "1.0.21", true},
|
||||
{"build_number_env", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("BUILD_NUMBER", "42")
|
||||
}, "1.0.21", true},
|
||||
{"run_id_env", func(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("RUN_ID", "abc")
|
||||
}, "1.0.21", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.setup(t)
|
||||
if got := shouldSkip(tt.version); got != tt.want {
|
||||
t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Independent opt-out: LARKSUITE_CLI_NO_SKILLS_NOTIFIER must NOT be
|
||||
// affected by LARKSUITE_CLI_NO_UPDATE_NOTIFIER (different env vars).
|
||||
func TestShouldSkip_OptOutIsIndependent(t *testing.T) {
|
||||
clearSkillsSkipEnv(t)
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") // update opt-out, not us
|
||||
if shouldSkip("1.0.21") {
|
||||
t.Error("shouldSkip(release) = true with only LARKSUITE_CLI_NO_UPDATE_NOTIFIER set, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_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 raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
|
||||
}
|
||||
|
||||
var state SkillsState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
|
||||
}
|
||||
return &state, true, nil
|
||||
}
|
||||
|
||||
func WriteState(state SkillsState) error {
|
||||
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.AddedOfficialSkills == nil {
|
||||
s.AddedOfficialSkills = []string{}
|
||||
}
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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{
|
||||
Version: "1.2.3",
|
||||
OfficialSkills: []string{"lark-doc", "lark-im"},
|
||||
UpdatedSkills: []string{"lark-doc"},
|
||||
AddedOfficialSkills: []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_CorruptStateUnreadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(`{"version":`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
state, ok, err := ReadState()
|
||||
if !errors.Is(err, ErrUnreadableState) {
|
||||
t.Fatalf("ReadState() err = %v, want ErrUnreadableState", 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.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.AddedOfficialSkills == 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
// 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 TestParseSkillsListIgnoresUnsupportedFormat(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)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("ParseSkillsList() = %#v, want empty result for unsupported format", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
input := `Available Skills
|
||||
│ lark-calendar
|
||||
│ official-shared
|
||||
│ bad/name
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "official-shared"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
lark-approval ~/.agents/skills/lark-approval
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
lark-attendance ~/.agents/skills/lark-attendance
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
lark-calendar ~/.agents/skills/lark-calendar
|
||||
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
|
||||
dogfood ~/.hermes/skills/dogfood
|
||||
Agents: Hermes Agent
|
||||
yuanbao ~/.hermes/skills/yuanbao
|
||||
Agents: Hermes Agent
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"dogfood", "lark-approval", "lark-attendance", "lark-base", "lark-calendar", "yuanbao"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithANSI(t *testing.T) {
|
||||
input := "\x1b[1mGlobal Skills\x1b[0m\n\n" +
|
||||
"\x1b[36mlark-calendar\x1b[0m \x1b[38;5;102m~/.agents/skills/lark-calendar\x1b[0m\n" +
|
||||
" \x1b[38;5;102mAgents:\x1b[0m TRAE CN, TRAE +3 more\n" +
|
||||
"\x1b[36mdogfood\x1b[0m \x1b[38;5;102m~/.hermes/skills/dogfood\x1b[0m\n" +
|
||||
" \x1b[38;5;102mAgents:\x1b[0m Hermes Agent\n" +
|
||||
"\nTip: Use the -y flag to run in non-interactive mode (for CI and AI agents).\n"
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"dogfood", "lark-calendar"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (ANSI Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
General
|
||||
lark-apps ~/.agents/skills/lark-apps
|
||||
lark-base ~/.agents/skills/lark-base
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-apps", "lark-base"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSON(t *testing.T) {
|
||||
input := `[
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
|
||||
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
|
||||
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
|
||||
{"name":""},
|
||||
{"name":" "},
|
||||
{"name":"bad skill"}
|
||||
]`
|
||||
got := ParseGlobalSkillsJSON(input)
|
||||
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`[]`,
|
||||
} {
|
||||
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
|
||||
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
|
||||
input := `{
|
||||
"skills": [
|
||||
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
|
||||
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
|
||||
{"name":" lark-base ","description":"Base","files":[]},
|
||||
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
|
||||
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
|
||||
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
|
||||
{"name":"","description":"empty","files":["SKILL.md"]}
|
||||
]
|
||||
}`
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
|
||||
}
|
||||
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`[{"name":"lark-calendar"}]`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`{"skills":[]}`,
|
||||
`{"skills":[{"name":"bad skill"}]}`,
|
||||
} {
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err == nil && len(got) != 0 {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
officialIndexOut string
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialIndexErr error
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
}
|
||||
|
||||
func officialSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Available Skills\n")
|
||||
for _, name := range names {
|
||||
b.WriteString("│ ")
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func officialSkillsIndexOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"skills":[`)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Global Skills\n\n")
|
||||
for _, name := range names {
|
||||
b.WriteString(name)
|
||||
b.WriteString(" ~/.agents/skills/")
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n Agents: Claude Code\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsJSONOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
|
||||
f.listedIndex++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialIndexOut)
|
||||
r.Err = f.officialIndexErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
f.listedOfficial++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
|
||||
f.listedGlobalJSON++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalJSONOut)
|
||||
r.Err = f.globalJSONErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
|
||||
f.listedGlobalText++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.globalOut)
|
||||
r.Err = f.globalErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSkill(nameList []string) *selfupdate.NpmResult {
|
||||
f.installed = append(f.installed, nameList)
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
f.installedAll++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installAllErr
|
||||
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{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
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[0], []string{"lark-calendar", "lark-new"})
|
||||
if runner.listedGlobalJSON != 1 {
|
||||
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
|
||||
}
|
||||
if runner.listedGlobalText != 0 {
|
||||
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
|
||||
}
|
||||
|
||||
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.AddedOfficialSkills, []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_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
if runner.listedIndex != 1 {
|
||||
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||
}
|
||||
if runner.listedOfficial != 0 {
|
||||
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatalf("SyncSkills() err = nil, want error")
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "full skills install failed") {
|
||||
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
|
||||
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
|
||||
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(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{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %d calls, want 1", len(runner.installed))
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatalf("SyncSkills() err = nil, want error")
|
||||
}
|
||||
if !strings.Contains(result.Detail, "incremental boom") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "full skills install failed") {
|
||||
t.Fatalf("SyncSkills() err = %v, want full install failure", result.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 TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_failed" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
|
||||
}
|
||||
if result.Err == nil {
|
||||
t.Fatalf("SyncSkills() err = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
assertStrings(t, state.OfficialSkills, []string{})
|
||||
assertStrings(t, state.UpdatedSkills, []string{})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{})
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
|
||||
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"})
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-calendar", "lark-mail"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
|
||||
assertStrings(t, result.SkippedDeleted, []string{})
|
||||
if !strings.Contains(result.Detail, "incremental boom") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result1.Action != "fallback_synced" {
|
||||
t.Fatalf("first sync: action = %q, want fallback_synced", result1.Action)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() after first sync = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Version != "1.0.33" {
|
||||
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
t.Fatalf("second sync: action = %q, want synced (no fallback loop)", result2.Action)
|
||||
}
|
||||
if runner2.installedAll != 0 {
|
||||
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user