Compare commits

..

1 Commits

Author SHA1 Message Date
guokexin.02
f151ca9ac1 feat: support lark suite hybrid skills layout 2026-07-03 17:43:52 +08:00
11 changed files with 953 additions and 62 deletions

View File

@@ -2,22 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
@@ -1371,7 +1355,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61

View File

@@ -86,10 +86,13 @@ func symArrow() string {
// UpdateOptions holds inputs for the update command.
type UpdateOptions struct {
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
Factory *cmdutil.Factory
JSON bool
Force bool
Check bool
SkillsLayout string
FlatSkills string
FlatSet bool
}
// NewCmdUpdate creates the update command.
@@ -108,6 +111,7 @@ Detects the installation method automatically:
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.FlatSet = cmd.Flags().Changed("flat-skills")
return updateRun(opts)
},
}
@@ -115,6 +119,8 @@ Use --check to only check for updates without installing.`,
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or hybrid")
cmd.Flags().StringVar(&opts.FlatSkills, "flat-skills", "", "comma-separated skills kept as top-level skills when the effective layout is hybrid")
cmdutil.SetRisk(cmd, "high-risk-write")
return cmd
@@ -122,6 +128,9 @@ Use --check to only check for updates without installing.`,
func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
if err := validateSkillsLayoutOptions(opts); err != nil {
return reportError(opts, io, "validation_error", err)
}
cur := currentVersion()
updater := newUpdater()
@@ -147,7 +156,7 @@ func updateRun(opts *UpdateOptions) error {
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -208,7 +217,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
reason := detect.ManualReason()
if opts.JSON {
@@ -287,7 +296,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
if opts.JSON {
result := map[string]interface{}{
@@ -324,16 +333,20 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
if !force {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedFlat string, flatSet bool) *skillscheck.SyncResult {
layout, flat := resolveSkillsSyncOptions(requestedLayout, requestedFlat, flatSet)
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
if !force && !layoutExplicit && !flatSet {
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
}
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
Force: force,
Runner: updater,
Version: stateVersion,
Layout: layout,
FlatSkills: flat,
Force: force,
Runner: updater,
})
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
@@ -341,6 +354,47 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
return result
}
func validateSkillsLayoutOptions(opts *UpdateOptions) errs.TypedError {
if _, ok := skillscheck.NormalizeLayout(opts.SkillsLayout); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or hybrid").WithParam("--skills-layout")
}
layout, flat := resolveSkillsSyncOptions(opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
if layout != skillscheck.LayoutHybrid {
return nil
}
for _, skill := range flat {
if skill == "lark-shared" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "lark-shared cannot be selected by --flat-skills; it is managed automatically for lark-suite compatibility").WithParam("--flat-skills")
}
}
return nil
}
func resolveSkillsSyncOptions(requestedLayout, requestedFlat string, flatSet bool) (string, []string) {
state, readable, err := skillscheck.ReadState()
if err != nil {
readable = false
state = nil
}
layout := skillscheck.LayoutSeparate
if strings.TrimSpace(requestedLayout) != "" {
layout, _ = skillscheck.NormalizeLayout(requestedLayout)
} else if readable && state != nil {
if stateLayout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
layout = stateLayout
}
}
if flatSet {
return layout, skillscheck.ParseFlatSkills(requestedFlat)
}
if readable && state != nil {
return layout, state.FlatSkills
}
return layout, []string{}
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
// already-up-to-date branch, including any skills_action / skills_warning
// fields derived from skillsResult. When check is true, this is the pure
@@ -387,6 +441,12 @@ func applySkillsStatus(env map[string]interface{}, target string) {
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
if state.Layout != "" {
status["layout"] = state.Layout
}
if len(state.FlatSkills) > 0 {
status["flat_skills"] = state.FlatSkills
}
env["skills_status"] = status
}
@@ -397,6 +457,7 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
env["skills_hint"] = skillsFailureHint()
env["skills_summary"] = skillsSummary(r)
default:
env["skills_action"] = "synced"
@@ -410,10 +471,17 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
"layout": r.Layout,
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
}
if len(r.Collected) > 0 {
summary["collected"] = r.Collected
}
if len(r.Flat) > 0 {
summary["flat"] = r.Flat
}
return summary
}
@@ -425,13 +493,17 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
if len(r.Failed) > 0 {
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
}
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
fmt.Fprintf(io.ErrOut, " %s\n", skillsFailureHint())
case r.Force:
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
}
}
func skillsFailureHint() string {
return "Retry: lark-cli update --force. To switch to separate top-level skills: lark-cli update --skills-layout separate (this saves layout=separate and clears saved flat_skills)."
}

View File

@@ -996,7 +996,7 @@ func newTestIO() *cmdutil.IOStreams {
func TestRunSkillsAndState_DedupHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
t.Fatal(err)
}
called := false
@@ -1006,7 +1006,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got != nil {
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
@@ -1015,6 +1015,27 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
}
}
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want successful sync when state lacks layout", got)
}
if !called {
t.Error("SkillsCommandOverride not called, want resync when state lacks layout")
}
}
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
@@ -1027,7 +1048,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
@@ -1039,7 +1060,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
@@ -1050,6 +1071,12 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
if state.Layout != skillscheck.LayoutSeparate {
t.Errorf("state.Layout = %q, want %q", state.Layout, skillscheck.LayoutSeparate)
}
if len(state.FlatSkills) != 0 {
t.Errorf("state.FlatSkills = %#v, want empty", state.FlatSkills)
}
}
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
@@ -1064,7 +1091,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
return r
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
@@ -1077,6 +1104,57 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
}
}
func TestResolveSkillsSyncOptions_UsesStateAsFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.21",
Layout: skillscheck.LayoutHybrid,
FlatSkills: []string{"lark-doc"},
}); err != nil {
t.Fatal(err)
}
layout, flat := resolveSkillsSyncOptions("", "", false)
if layout != skillscheck.LayoutHybrid {
t.Fatalf("layout = %q, want %q", layout, skillscheck.LayoutHybrid)
}
if len(flat) != 1 || flat[0] != "lark-doc" {
t.Fatalf("flat = %#v, want [lark-doc]", flat)
}
}
func TestValidateSkillsLayoutOptionsRejectsSuiteMode(t *testing.T) {
err := validateSkillsLayoutOptions(&UpdateOptions{SkillsLayout: "suite"})
if err == nil {
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
}
if validation.Param != "--skills-layout" {
t.Fatalf("validation.Param = %q, want --skills-layout", validation.Param)
}
}
func TestValidateSkillsLayoutOptionsRejectsSharedFlatInHybrid(t *testing.T) {
err := validateSkillsLayoutOptions(&UpdateOptions{
SkillsLayout: skillscheck.LayoutHybrid,
FlatSkills: "lark-shared",
FlatSet: true,
})
if err == nil {
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
}
if validation.Param != "--flat-skills" {
t.Fatalf("validation.Param = %q, want --flat-skills", validation.Param)
}
}
func TestTruncate(t *testing.T) {
long := strings.Repeat("x", 3000)
got := selfupdate.Truncate(long, 2000)
@@ -1357,7 +1435,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
t.Cleanup(func() { syncSkills = origSync })
f, _, stderr := newTestFactory(t)
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}

View File

@@ -49,6 +49,8 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
)
// DetectResult holds installation detection results.
@@ -242,6 +244,14 @@ func (u *Updater) InstallAllSkills() *NpmResult {
return r
}
func (u *Updater) InstallSuiteSkill() *NpmResult {
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
if r.Err != nil {
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}

View File

@@ -208,6 +208,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
},
{
name: "install isolated suite skill",
run: func(u *Updater) *NpmResult {
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
},
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
},
}
for _, tt := range tests {
@@ -238,6 +245,34 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
called := []string{}
u := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), isolatedSkillsSourceURL) {
r.Err = fmt.Errorf("isolated source unavailable")
}
return r
},
}
result := u.InstallSuiteSkill()
if result.Err != nil {
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("calls = %#v, want primary and fallback", called)
}
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
t.Fatalf("primary call = %q, want isolated source", called[0])
}
if !strings.Contains(called[1], isolatedSkillsFallback) {
t.Fatalf("fallback call = %q, want isolated GitHub fallback", called[1])
}
}
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)

View File

@@ -0,0 +1,374 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
const (
LayoutSeparate = "separate"
LayoutHybrid = "hybrid"
suiteSkillName = "lark-suite"
sharedSkillName = "lark-shared"
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
)
type GlobalSkillInfo struct {
Name string
Path string
}
func NormalizeLayout(layout string) (string, bool) {
switch strings.TrimSpace(layout) {
case "", LayoutSeparate:
return LayoutSeparate, true
case LayoutHybrid:
return LayoutHybrid, true
default:
return "", false
}
}
func ParseFlatSkills(value string) []string {
seen := map[string]bool{}
for _, part := range strings.Split(value, ",") {
name := strings.TrimSpace(part)
if name != "" {
seen[name] = true
}
}
return sortedKeys(seen)
}
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
infos, _ := parseGlobalSkillInfosJSON(text)
return infos
}
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
type globalSkill struct {
Name string `json:"name"`
Path string `json:"path"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil, false
}
seen := map[string]GlobalSkillInfo{}
for _, skill := range skills {
name := strings.TrimSpace(skill.Name)
path := strings.TrimSpace(skill.Path)
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
continue
}
seen[name] = GlobalSkillInfo{Name: name, Path: path}
}
out := make([]GlobalSkillInfo, 0, len(seen))
for _, info := range seen {
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, true
}
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
seen := map[string]bool{}
for _, info := range infos {
seen[info.Name] = true
if info.Name == suiteSkillName {
for _, subskill := range listSuiteSubskills(info.Path) {
seen[subskill] = true
}
}
}
return sortedKeys(seen)
}
func listSuiteSubskills(suitePath string) []string {
entries, err := os.ReadDir(filepath.Join(suitePath, "references", "subskills"))
if err != nil {
return nil
}
seen := map[string]bool{}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
if name != "" && skillNamePattern.MatchString(name) {
seen[name] = true
}
}
return sortedKeys(seen)
}
func normalOfficialSkills(skills []string) []string {
out := []string{}
for _, skill := range uniqueSorted(skills) {
if skill != suiteSkillName {
out = append(out, skill)
}
}
return out
}
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
if force || !stateReadable || previous == nil {
return []string{}
}
officialSet := toSet(official)
localSet := toSet(local)
deleted := map[string]bool{}
for _, skill := range previous.OfficialSkills {
if !officialSet[skill] || localSet[skill] {
continue
}
if layout != LayoutSeparate && skill == sharedSkillName {
continue
}
deleted[skill] = true
}
return sortedKeys(deleted)
}
func suiteEffectiveSkills(official []string, deleted map[string]bool) []string {
out := []string{}
for _, skill := range normalOfficialSkills(official) {
if !deleted[skill] {
out = append(out, skill)
}
}
return uniqueSorted(out)
}
func resolveHybridSkillSets(layout string, requestedFlat, official []string, skippedDeleted []string) ([]string, []string, error) {
if layout == LayoutSeparate {
return []string{}, []string{}, nil
}
officialSet := toSet(official)
deletedSet := toSet(skippedDeleted)
configuredFlat := map[string]bool{}
effectiveFlat := map[string]bool{}
for _, skill := range uniqueSorted(requestedFlat) {
if skill == sharedSkillName {
return nil, nil, fmt.Errorf("%s cannot be selected by --flat-skills", sharedSkillName)
}
if !officialSet[skill] {
return nil, nil, fmt.Errorf("flat skill %q is not in official skills", skill)
}
configuredFlat[skill] = true
if !deletedSet[skill] {
effectiveFlat[skill] = true
}
}
collected := []string{}
for _, skill := range normalOfficialSkills(official) {
if skill == sharedSkillName {
collected = append(collected, skill)
continue
}
if deletedSet[skill] || effectiveFlat[skill] {
continue
}
collected = append(collected, skill)
}
return sortedKeys(configuredFlat), uniqueSortedWithFirst(collected, sharedSkillName), nil
}
func uniqueSortedWithFirst(values []string, first string) []string {
seen := toSet(values)
if !seen[first] {
return sortedKeys(seen)
}
delete(seen, first)
return append([]string{first}, sortedKeys(seen)...)
}
func assembleSuiteLayout(layout string, collected []string, keepSharedTopLevel bool, infos []GlobalSkillInfo) error {
if layout == LayoutSeparate {
return nil
}
infoByName := map[string]GlobalSkillInfo{}
for _, info := range infos {
infoByName[info.Name] = info
}
suiteInfo, ok := infoByName[suiteSkillName]
if !ok {
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
}
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
if err := os.RemoveAll(subskillsDir); err != nil {
return err
}
if err := os.MkdirAll(subskillsDir, 0o755); err != nil {
return err
}
for _, skill := range collected {
info, ok := infoByName[skill]
if !ok {
return fmt.Errorf("suite subskill %q was not installed", skill)
}
dst := filepath.Join(subskillsDir, skill)
if keepSharedTopLevel && skill == sharedSkillName {
if err := copyDir(info.Path, dst); err != nil {
return err
}
continue
}
if err := moveDir(info.Path, dst); err != nil {
return err
}
}
return renderSuiteRoutes(suiteInfo.Path, collected)
}
func renderSuiteRoutes(suitePath string, collected []string) error {
skillPath := filepath.Join(suitePath, "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
return err
}
text := normalizeSuiteTemplateText(string(data))
routes := []string{}
for _, skill := range collected {
desc := skillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
if desc == "" {
desc = skill
}
routes = append(routes, fmt.Sprintf("- %s: %s", skill, desc))
}
if !strings.Contains(text, suiteRoutesPlaceholder) {
return fmt.Errorf("%s route placeholder not found", suiteSkillName)
}
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
return os.WriteFile(skillPath, []byte(text), 0o644)
}
func normalizeSuiteTemplateText(text string) string {
text = strings.ReplaceAll(text, "--collected-skills", "--flat-skills")
oldShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
newShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。"
return strings.ReplaceAll(text, oldShared, newShared)
}
func skillDescription(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(string(data), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return ""
}
for i, line := range lines[1:] {
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
return ""
}
if strings.HasPrefix(trimmed, "description:") {
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
if value == ">" || value == "|" {
return foldedYAMLScalar(lines[i+2:])
}
return strings.Trim(value, `"'`)
}
}
return ""
}
func foldedYAMLScalar(lines []string) string {
parts := []string{}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed == "---" || !isIndentedYAMLLine(line) {
break
}
parts = append(parts, trimmed)
}
return strings.Join(parts, " ")
}
func isIndentedYAMLLine(line string) bool {
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
}
func moveDir(src, dst string) error {
if err := os.RemoveAll(dst); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.Rename(src, dst); err == nil {
return nil
}
if err := copyDir(src, dst); err != nil {
return err
}
return os.RemoveAll(src)
}
func copyDir(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", src)
}
if err := os.RemoveAll(dst); err != nil {
return err
}
return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if entry.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}

View File

@@ -23,10 +23,12 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
type SkillsState struct {
Version string `json:"version"`
Layout string `json:"layout,omitempty"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
FlatSkills []string `json:"flat_skills"`
UpdatedAt string `json:"updated_at"`
}
@@ -76,6 +78,14 @@ func ReadSyncedVersion() (string, bool) {
return state.Version, true
}
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
state, readable, err := ReadState()
if err != nil || !readable || state.Version == "" {
return "", "", false
}
return state.Version, state.Layout, true
}
func (s *SkillsState) ensureNonNilSlices() {
if s.OfficialSkills == nil {
s.OfficialSkills = []string{}
@@ -89,4 +99,7 @@ func (s *SkillsState) ensureNonNilSlices() {
if s.SkippedDeletedSkills == nil {
s.SkippedDeletedSkills = []string{}
}
if s.FlatSkills == nil {
s.FlatSkills = []string{}
}
}

View File

@@ -21,6 +21,7 @@ var (
type SyncInput struct {
Version string
Layout string
OfficialSkills []string
LocalSkills []string
PreviousState *SkillsState
@@ -195,7 +196,18 @@ func parseOfficialSkillsList(lines []string) []string {
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
official := normalOfficialSkills(input.OfficialSkills)
layout, _ := NormalizeLayout(input.Layout)
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
if layout != LayoutSeparate {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: suiteEffectiveSkills(official, toSet(skippedDeleted)),
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
SkippedDeleted: skippedDeleted,
}
}
if input.Force {
return SyncPlan{
Version: input.Version,
@@ -229,19 +241,12 @@ func PlanSync(input SyncInput) SyncPlan {
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,
SkippedDeleted: skippedDeleted,
}
}
@@ -252,13 +257,16 @@ type SkillsRunner interface {
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
InstallSuiteSkill() *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Force bool
Runner SkillsRunner
Now func() time.Time
Version string
Layout string
FlatSkills []string
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
@@ -271,6 +279,9 @@ type SyncResult struct {
Err error
Detail string
Force bool
Layout string
Flat []string
Collected []string
}
func SyncSkills(opts SyncOptions) *SyncResult {
@@ -280,16 +291,26 @@ func SyncSkills(opts SyncOptions) *SyncResult {
if opts.Runner == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
layout, ok := NormalizeLayout(opts.Layout)
if !ok {
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
}
// --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
}
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
}
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
@@ -302,12 +323,17 @@ func SyncSkills(opts SyncOptions) *SyncResult {
plan := PlanSync(SyncInput{
Version: opts.Version,
Layout: layout,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
flat, collected, err := resolveHybridSkillSets(layout, opts.FlatSkills, plan.OfficialSkills, plan.SkippedDeleted)
if err != nil {
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
}
result := &SyncResult{
Action: "synced",
@@ -316,25 +342,59 @@ func SyncSkills(opts SyncOptions) *SyncResult {
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
Layout: layout,
Flat: flat,
Collected: collected,
}
if len(plan.ToUpdate) == 0 {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
}
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 {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
}
return fallbackFullInstall(opts, resultDetail(installResult), official)
}
}
if layout != LayoutSeparate {
installSuiteResult := opts.Runner.InstallSuiteSkill()
if installSuiteResult == nil || installSuiteResult.Err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
result.Detail = resultDetail(installSuiteResult)
return result
}
infosResult := opts.Runner.ListGlobalSkillsJSON()
if infosResult == nil || infosResult.Err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
result.Detail = resultDetail(infosResult)
return result
}
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
keepSharedTopLevel := layout == LayoutHybrid && len(flat) > 0
if err := assembleSuiteLayout(layout, collected, keepSharedTopLevel, infos); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
return result
}
}
state := SkillsState{
Version: opts.Version,
Layout: layout,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
FlatSkills: stateFlatSkills(layout, flat),
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
@@ -346,6 +406,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
return &SyncResult{
Action: "failed",
Err: err,
Detail: detail,
Force: force,
Layout: layout,
}
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
@@ -383,8 +453,9 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
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
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
if valid {
return installedSkillNamesFromInfos(infos), true
}
}
@@ -411,6 +482,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
Detail: reason,
Force: opts.Force,
Layout: LayoutSeparate,
}
}
if installResult.Err != nil {
@@ -419,11 +491,13 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
Detail: reason + "\n" + resultDetail(installResult),
Force: opts.Force,
Layout: LayoutSeparate,
}
}
state := SkillsState{
Version: opts.Version,
Layout: LayoutSeparate,
OfficialSkills: official,
UpdatedSkills: official,
AddedOfficialSkills: official,
@@ -439,6 +513,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
SkippedDeleted: []string{},
Detail: reason + "\nstate write failed: " + writeErr.Error(),
Force: opts.Force,
Layout: LayoutSeparate,
}
}
@@ -450,9 +525,38 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
SkippedDeleted: []string{},
Detail: reason,
Force: opts.Force,
Layout: LayoutSeparate,
}
}
func stateFlatSkills(layout string, requested []string) []string {
if layout != LayoutHybrid {
return []string{}
}
out := []string{}
for _, skill := range uniqueSorted(requested) {
if skill != sharedSkillName {
out = append(out, skill)
}
}
return out
}
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
previousOfficial := []string{}
if stateReadable && previous != nil {
previousOfficial = previous.OfficialSkills
}
previousSet := toSet(previousOfficial)
added := []string{}
for _, skill := range official {
if !previousSet[skill] {
added = append(added, skill)
}
}
return uniqueSorted(added)
}
func resultDetail(result *selfupdate.NpmResult) string {
if result == nil {
return ""

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
@@ -216,8 +217,10 @@ type fakeSkillsRunner struct {
globalErr error
installErr error
installAllErr error
installSuiteErr error
installed [][]string
installedAll int
installedSuite int
listedIndex int
listedOfficial int
listedGlobalJSON int
@@ -319,6 +322,13 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
return r
}
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
f.installedSuite++
r := &selfupdate.NpmResult{}
r.Err = f.installSuiteErr
return r
}
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -574,7 +584,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
@@ -585,14 +595,15 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
}
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 result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
if len(runner.installed) != 1 {
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
@@ -697,6 +708,115 @@ func TestSyncSkills_NilRunnerFails(t *testing.T) {
}
}
func TestSyncSkills_HybridAssemblesSuiteAndMovesCollectedSkills(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
paths := map[string]string{}
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
paths[name] = filepath.Join(dir, name)
writeTestSkill(t, paths[name], name)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
globalJSONOut: globalSkillsJSONFromPaths(paths),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
FlatSkills: []string{"lark-calendar"},
Runner: runner,
Now: time.Now,
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if runner.installedSuite != 1 {
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
}
assertStrings(t, result.Flat, []string{"lark-calendar"})
assertStrings(t, result.Collected, []string{"lark-shared", "lark-doc"})
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-doc", "SKILL.md")); err != nil {
t.Fatalf("suite lark-doc missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
t.Fatalf("suite lark-shared missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-calendar"], "SKILL.md")); err != nil {
t.Fatalf("flat lark-calendar missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-doc"], "SKILL.md")); !os.IsNotExist(err) {
t.Fatalf("collected lark-doc still exists at top level or unexpected err: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); err != nil {
t.Fatalf("lark-shared should stay top-level when flat set is non-empty: %v", err)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Layout != LayoutHybrid {
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutHybrid)
}
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
}
func TestSyncSkills_HybridWithNoFlatSkillsDoesNotKeepSharedTopLevel(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
paths := map[string]string{}
for _, name := range []string{"lark-shared", "lark-suite"} {
paths[name] = filepath.Join(dir, name)
writeTestSkill(t, paths[name], name)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-shared"),
globalJSONOut: globalSkillsJSONFromPaths(paths),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
Runner: runner,
Now: time.Now,
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); !os.IsNotExist(err) {
t.Fatalf("lark-shared should not stay top-level when flat set is empty; err: %v", err)
}
if result.Flat == nil || len(result.Flat) != 0 {
t.Fatalf("result.Flat = %#v, want empty slice", result.Flat)
}
}
func TestSyncSkills_HybridRejectsSharedFlatSkill(t *testing.T) {
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-shared"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
FlatSkills: []string{"lark-shared"},
Runner: runner,
Now: time.Now,
})
if result.Err == nil || !strings.Contains(result.Err.Error(), "lark-shared") {
t.Fatalf("SyncSkills() err = %v, want lark-shared validation error", result.Err)
}
}
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -733,6 +853,43 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
}
}
func TestNormalizeSuiteTemplateTextRewritesLegacyFlatSkillWording(t *testing.T) {
input := "`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
got := normalizeSuiteTemplateText(input)
if strings.Contains(got, "--collected-skills") {
t.Fatalf("normalized text still contains legacy flag: %s", got)
}
if !strings.Contains(got, "--flat-skills") {
t.Fatalf("normalized text missing --flat-skills: %s", got)
}
if !strings.Contains(got, "只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份") {
t.Fatalf("normalized text missing current lark-shared rule: %s", got)
}
}
func TestSkillDescriptionSupportsFoldedYAMLScalar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "SKILL.md")
content := `---
name: lark-whiteboard
description: >
飞书画板:查询和编辑飞书云文档中的画板。
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。
metadata:
requires:
bins: ["lark-cli"]
---
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
got := skillDescription(path)
want := "飞书画板:查询和编辑飞书云文档中的画板。 当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。"
if got != want {
t.Fatalf("skillDescription() = %q, want %q", got, want)
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
@@ -740,6 +897,38 @@ func assertStrings(t *testing.T, got, want []string) {
}
}
func writeTestSkill(t *testing.T, dir, name string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
content := fmt.Sprintf("---\nname: %s\ndescription: %s description\n---\n", name, name)
if name == suiteSkillName {
content = "---\nname: lark-suite\ndescription: Lark suite\n---\n<!-- LARK_SUITE_ROUTES -->\n"
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func globalSkillsJSONFromPaths(paths map[string]string) string {
names := make([]string, 0, len(paths))
for name := range paths {
names = append(names, name)
}
sort.Strings(names)
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, paths[name])
}
b.WriteString("]")
return b.String()
}
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)

View File

@@ -0,0 +1,33 @@
---
name: lark-suite
version: 0.1.0
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
metadata:
requires:
bins:
- lark-cli
---
# Lark Suite
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
## 使用流程
1. 根据用户意图从下方路由表选择一个或多个子能力即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
2. 读取对应子能力说明,优先使用 `references/subskills/<skill-name>/SKILL.md`
3. 如果目标能力没有出现在本文件中,不代表当前环境不可用;检查是否存在相关的独立 `lark-*` skill。
4. 如果已选中的子能力说明列出必读、前置或继续阅读的文件,只读取该子能力当前任务所需的前置文件;不要读取无关子能力。
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`
`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
## 能力路由
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
<!-- LARK_SUITE_ROUTES -->

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.65",
"version": "1.0.64",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"