diff --git a/cmd/update/update.go b/cmd/update/update.go index 43a047b2e..e639a1de2 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -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)." +} diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index faf2f7629..3a92dd0b1 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -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) } diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index 9304f7c3e..35ae6a41f 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -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") } diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index 65426eeb1..4ce3d059b 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -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"}]}`) diff --git a/internal/skillscheck/layout.go b/internal/skillscheck/layout.go new file mode 100644 index 000000000..038cbf9c6 --- /dev/null +++ b/internal/skillscheck/layout.go @@ -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 = "" +) + +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 +} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index eddab1cf3..093119241 100644 --- a/internal/skillscheck/state.go +++ b/internal/skillscheck/state.go @@ -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{} + } } diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go index 2f8adb3db..3255cdf65 100644 --- a/internal/skillscheck/sync.go +++ b/internal/skillscheck/sync.go @@ -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 "" diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go index fb8f117fc..02f240f03 100644 --- a/internal/skillscheck/sync_test.go +++ b/internal/skillscheck/sync_test.go @@ -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\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) diff --git a/isolated-skills/lark-suite/SKILL.md b/isolated-skills/lark-suite/SKILL.md new file mode 100644 index 000000000..3b5a9c6fc --- /dev/null +++ b/isolated-skills/lark-suite/SKILL.md @@ -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.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` 发消息。 + +## 能力路由 + +根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。 + +