diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0df71cc0..fe867ab2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,6 +2,7 @@ # Last match wins: existing domains below are exempt, only new skills/ entries need review. /skills/ @liangshuo-1 +/isolated-skills/ @liangshuo-1 /skills/lark-approval/ /skills/lark-apps/ /skills/lark-attendance/ diff --git a/.github/workflows/skill-format-check.yml b/.github/workflows/skill-format-check.yml index 7c8b8fc5..d5a2c02e 100644 --- a/.github/workflows/skill-format-check.yml +++ b/.github/workflows/skill-format-check.yml @@ -5,12 +5,14 @@ on: branches: [main] paths: - "skills/**" + - "isolated-skills/**" - "scripts/skill-format-check/**" - ".github/workflows/skill-format-check.yml" pull_request: branches: [main] paths: - "skills/**" + - "isolated-skills/**" - "scripts/skill-format-check/**" - ".github/workflows/skill-format-check.yml" diff --git a/cmd/update/update.go b/cmd/update/update.go index 6b8ce509..8581458d 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -85,10 +85,12 @@ 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 + CollectedSkills string } // NewCmdUpdate creates the update command. @@ -114,6 +116,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, suite, or hybrid") + cmd.Flags().StringVar(&opts.CollectedSkills, "collected-skills", "", "comma-separated skills collected into lark-suite; only valid with --skills-layout hybrid") cmdutil.SetRisk(cmd, "high-risk-write") return cmd @@ -121,6 +125,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, output.ExitValidation, "validation_error", "%s", err) + } cur := currentVersion() updater := newUpdater() @@ -144,7 +151,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.CollectedSkills, !opts.JSON) } return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check) } @@ -202,7 +209,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.CollectedSkills, !opts.JSON) reason := detect.ManualReason() if opts.JSON { @@ -280,7 +287,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.CollectedSkills, !opts.JSON) if opts.JSON { result := map[string]interface{}{ @@ -317,23 +324,77 @@ 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, requestedCollected string, allowInteractiveFallback bool) *skillscheck.SyncResult { + layout, collected := resolveSkillsSyncOptions(requestedLayout, requestedCollected) + layoutExplicit := strings.TrimSpace(requestedLayout) != "" + if !force && !layoutExplicit { + 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, + CollectedSkills: collected, + Force: force, + Runner: updater, }) + if result.Err != nil && result.CanFallback && allowInteractiveFallback && io.IsTerminal && confirmSeparateFallback(io, layout, result.Err) { + result = syncSkills(skillscheck.SyncOptions{ + Version: stateVersion, + Layout: skillscheck.LayoutSeparate, + 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) } return result } +func confirmSeparateFallback(io *cmdutil.IOStreams, layout string, err error) bool { + fmt.Fprintf(io.ErrOut, "Failed to install %s skills layout: %v\n", layout, err) + fmt.Fprintf(io.ErrOut, "Use separate layout instead? [y/N]: ") + var answer string + if _, scanErr := fmt.Fscan(io.In, &answer); scanErr != nil { + fmt.Fprintln(io.ErrOut) + return false + } + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes" +} + +func validateSkillsLayoutOptions(opts *UpdateOptions) error { + layout, ok := skillscheck.NormalizeLayout(opts.SkillsLayout) + if !ok { + return fmt.Errorf("--skills-layout must be one of separate, suite, or hybrid") + } + if opts.CollectedSkills != "" && layout != skillscheck.LayoutHybrid { + return fmt.Errorf("--collected-skills can only be used with --skills-layout hybrid") + } + for _, skill := range skillscheck.ParseCollectedSkills(opts.CollectedSkills) { + if skill == "lark-shared" { + return fmt.Errorf("lark-shared cannot be selected by --collected-skills; hybrid keeps it both at top level and inside lark-suite for compatibility") + } + } + return nil +} + +func resolveSkillsSyncOptions(requestedLayout, requestedCollected string) (string, []string) { + if strings.TrimSpace(requestedLayout) != "" { + layout, _ := skillscheck.NormalizeLayout(requestedLayout) + return layout, skillscheck.ParseCollectedSkills(requestedCollected) + } + state, readable, err := skillscheck.ReadState() + if err == nil && readable { + if layout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" { + return layout, state.CollectedSkills + } + } + return skillscheck.LayoutSeparate, []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 @@ -380,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.CollectedSkills) > 0 { + status["collected_skills"] = state.CollectedSkills + } env["skills_status"] = status } @@ -407,6 +474,12 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} { if len(r.Failed) > 0 { summary["failed"] = r.Failed } + if r.Layout != "" { + summary["layout"] = r.Layout + } + if len(r.Collected) > 0 { + summary["collected"] = r.Collected + } return summary } @@ -420,9 +493,9 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) { } fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n") 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") } diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 5ab102be..672af23b 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -918,9 +918,21 @@ func newTestIO() *cmdutil.IOStreams { return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) } +func assertStringsEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %#v, want %#v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("got %#v, want %#v", got, want) + } + } +} + 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 @@ -930,7 +942,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) } @@ -939,11 +951,72 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) { } } -func TestRunSkillsAndState_DedupForceBypass(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) } + origSync := syncSkills + called := false + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { + called = true + if opts.Layout != skillscheck.LayoutSeparate { + t.Fatalf("opts.Layout = %q, want %q", opts.Layout, skillscheck.LayoutSeparate) + } + return &skillscheck.SyncResult{Action: "synced", Layout: opts.Layout} + } + t.Cleanup(func() { syncSkills = origSync }) + + got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "", "", false) + if got == nil || got.Err != nil { + t.Fatalf("runSkillsAndState() = %+v, want sync result", got) + } + if !called { + t.Fatal("syncSkills not called; missing layout must not dedup") + } +} + +func TestRunSkillsAndState_InteractiveFallbackToSeparate(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + origSync := syncSkills + calls := []string{} + syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { + calls = append(calls, opts.Layout) + if len(calls) == 1 { + return &skillscheck.SyncResult{ + Action: "failed", + Err: fmt.Errorf("special source failed"), + Layout: opts.Layout, + CanFallback: true, + } + } + if opts.Layout != skillscheck.LayoutSeparate { + t.Fatalf("fallback opts.Layout = %q, want %q", opts.Layout, skillscheck.LayoutSeparate) + } + return &skillscheck.SyncResult{Action: "synced", Layout: opts.Layout} + } + t.Cleanup(func() { syncSkills = origSync }) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + io := cmdutil.NewIOStreams(strings.NewReader("y\n"), stdout, stderr) + io.IsTerminal = true + got := runSkillsAndState(&selfupdate.Updater{}, io, "1.0.21", false, skillscheck.LayoutSuite, "", true) + + if got == nil || got.Err != nil || got.Layout != skillscheck.LayoutSeparate { + t.Fatalf("runSkillsAndState() = %+v, want successful separate fallback", got) + } + assertStringsEqual(t, calls, []string{skillscheck.LayoutSuite, skillscheck.LayoutSeparate}) + if !strings.Contains(stderr.String(), "Use separate layout instead?") { + t.Fatalf("stderr = %q, want fallback prompt", stderr.String()) + } +} + +func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil { + t.Fatal(err) + } called := false updater := &selfupdate.Updater{ SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { @@ -951,7 +1024,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) } @@ -963,7 +1036,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) } @@ -988,7 +1061,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) } @@ -1001,6 +1074,49 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) { } } +func TestValidateSkillsLayoutOptions(t *testing.T) { + tests := []struct { + name string + opts UpdateOptions + want string + }{ + { + name: "collected without hybrid", + opts: UpdateOptions{SkillsLayout: skillscheck.LayoutSeparate, CollectedSkills: "lark-im"}, + want: "--collected-skills can only be used with --skills-layout hybrid", + }, + { + name: "shared cannot be collected", + opts: UpdateOptions{SkillsLayout: skillscheck.LayoutHybrid, CollectedSkills: "lark-shared"}, + want: "lark-shared cannot be selected", + }, + { + name: "unknown layout", + opts: UpdateOptions{SkillsLayout: "compact"}, + want: "--skills-layout must be one of separate, suite, or hybrid", + }, + { + name: "hybrid collected ok", + opts: UpdateOptions{SkillsLayout: skillscheck.LayoutHybrid, CollectedSkills: "lark-im,lark-base"}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSkillsLayoutOptions(&tt.opts) + if tt.want == "" { + if err != nil { + t.Fatalf("validateSkillsLayoutOptions() err = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("validateSkillsLayoutOptions() err = %v, want containing %q", err, tt.want) + } + }) + } +} + func TestTruncate(t *testing.T) { long := strings.Repeat("x", 3000) got := selfupdate.Truncate(long, 2000) @@ -1281,7 +1397,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 9304f7c3..35ae6a41 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 65426eeb..b0938686 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 suite skill isolated source", + 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 { @@ -371,3 +378,32 @@ func TestListOfficialSkillsFallsBack(t *testing.T) { t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1]) } } + +func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) { + called := []string{} + updater := &Updater{ + SkillsCommandOverride: func(args ...string) *NpmResult { + call := strings.Join(args, " ") + called = append(called, call) + r := &NpmResult{} + if strings.Contains(call, isolatedSkillsSourceURL) { + r.Err = fmt.Errorf("primary failed") + } + return r + }, + } + + result := updater.InstallSuiteSkill() + if result.Err != nil { + t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err) + } + if len(called) != 2 { + t.Fatalf("called %d commands, want 2: %#v", len(called), called) + } + if !strings.Contains(called[0], isolatedSkillsSourceURL) { + t.Fatalf("primary call = %q, want %s", called[0], isolatedSkillsSourceURL) + } + if !strings.Contains(called[1], isolatedSkillsFallback) { + t.Fatalf("fallback call = %q, want %s", called[1], isolatedSkillsFallback) + } +} diff --git a/internal/skillscheck/layout.go b/internal/skillscheck/layout.go new file mode 100644 index 00000000..917a5983 --- /dev/null +++ b/internal/skillscheck/layout.go @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skillscheck + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + LayoutSeparate = "separate" + LayoutSuite = "suite" + 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 LayoutSuite: + return LayoutSuite, true + case LayoutHybrid: + return LayoutHybrid, true + default: + return "", false + } +} + +func ParseCollectedSkills(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 { + continue + } + out = append(out, skill) + } + return out +} + +func resolveCollectedSkills(layout string, requested, official []string, previous *SkillsState, stateReadable bool, skippedDeleted []string) ([]string, error) { + officialSet := toSet(official) + deletedSet := toSet(skippedDeleted) + switch layout { + case LayoutSeparate: + return []string{}, nil + case LayoutSuite: + return suiteEffectiveSkills(official, deletedSet), nil + case LayoutHybrid: + collected := []string{} + for _, skill := range uniqueSorted(requested) { + if skill == sharedSkillName { + return nil, fmt.Errorf("%s is not selectable in hybrid layout", sharedSkillName) + } + if !officialSet[skill] { + return nil, fmt.Errorf("collected skill %q is not in official skills", skill) + } + if !deletedSet[skill] { + collected = append(collected, skill) + } + } + for _, skill := range newlyOfficialSkills(official, previous, stateReadable) { + if skill != sharedSkillName && !deletedSet[skill] { + collected = append(collected, skill) + } + } + if officialSet[sharedSkillName] { + collected = append([]string{sharedSkillName}, collected...) + } + return uniqueSortedWithFirst(collected, sharedSkillName), nil + default: + return nil, fmt.Errorf("unsupported skills layout %q", layout) + } +} + +func suiteEffectiveSkills(official []string, deletedSet map[string]bool) []string { + out := []string{} + for _, skill := range normalOfficialSkills(official) { + if skill == sharedSkillName || !deletedSet[skill] { + out = append(out, skill) + } + } + return out +} + +func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string { + if !stateReadable || previous == nil { + return []string{} + } + previousSet := toSet(previous.OfficialSkills) + out := []string{} + for _, skill := range normalOfficialSkills(official) { + if !previousSet[skill] { + out = append(out, skill) + } + } + return out +} + +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, 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("collected skill %q was not installed", skill) + } + dst := filepath.Join(subskillsDir, skill) + if layout == LayoutHybrid && skill == sharedSkillName { + if err := copyDir(info.Path, dst); err != nil { + return err + } + continue + } + if err := moveDir(info.Path, dst); err != nil { + return err + } + } + + return rewriteSuiteRoutes(suiteInfo.Path, collected) +} + +func moveDir(src, dst string) error { + if samePath(src, dst) { + return nil + } + 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 { + if samePath(src, dst) { + return nil + } + if err := os.RemoveAll(dst); err != nil { + return err + } + return filepath.WalkDir(src, func(path string, d 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) + info, err := d.Info() + if err != nil { + return err + } + if d.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(link, target) + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) 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_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +func samePath(a, b string) bool { + aa, errA := filepath.Abs(a) + bb, errB := filepath.Abs(b) + return errA == nil && errB == nil && aa == bb +} + +func rewriteSuiteRoutes(suitePath string, collected []string) error { + skillPath := filepath.Join(suitePath, "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + return err + } + text := string(data) + if !strings.Contains(text, suiteRoutesPlaceholder) { + return fmt.Errorf("%s does not contain route placeholder", skillPath) + } + + routes := []string{} + for _, skill := range collected { + description, err := readSkillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md")) + if err != nil { + return err + } + routes = append(routes, fmt.Sprintf("- %s: %s", skill, oneLine(description))) + } + text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1) + return os.WriteFile(skillPath, []byte(text), 0o644) +} + +func readSkillDescription(skillPath string) (string, error) { + data, err := os.ReadFile(skillPath) + if err != nil { + return "", err + } + text := string(data) + if !strings.HasPrefix(text, "---") { + return "", fmt.Errorf("missing frontmatter in %s", skillPath) + } + parts := strings.SplitN(text, "---", 3) + if len(parts) < 3 { + return "", fmt.Errorf("missing frontmatter in %s", skillPath) + } + lines := strings.Split(parts[1], "\n") + for i, line := range lines { + if !strings.HasPrefix(line, "description:") { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(line, "description:")) + if value == "|" || value == ">" { + block := []string{} + for _, blockLine := range lines[i+1:] { + if strings.TrimSpace(blockLine) == "" { + continue + } + if !strings.HasPrefix(blockLine, " ") { + break + } + block = append(block, strings.TrimSpace(blockLine)) + } + return strings.Join(block, " "), nil + } + return strings.Trim(value, `"'`), nil + } + return "", errors.New("missing frontmatter description") +} + +func oneLine(value string) string { + return strings.Join(strings.Fields(value), " ") +} diff --git a/internal/skillscheck/state.go b/internal/skillscheck/state.go index eddab1cf..09f5f15a 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"` + CollectedSkills []string `json:"collected_skills,omitempty"` 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.CollectedSkills == nil { + s.CollectedSkills = []string{} + } } diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go index 2f8adb3d..4675b9be 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,19 @@ 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 { + toUpdate := suiteEffectiveSkills(official, toSet(skippedDeleted)) + return SyncPlan{ + Version: input.Version, + OfficialSkills: official, + ToUpdate: toUpdate, + Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable), + SkippedDeleted: skippedDeleted, + } + } if input.Force { return SyncPlan{ Version: input.Version, @@ -229,22 +242,34 @@ 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, } } +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) +} + type SkillsRunner interface { ListOfficialSkillsIndex() *selfupdate.NpmResult ListOfficialSkills() *selfupdate.NpmResult @@ -252,13 +277,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 + CollectedSkills []string + Force bool + Runner SkillsRunner + Now func() time.Time } type SyncResult struct { @@ -271,6 +299,9 @@ type SyncResult struct { Err error Detail string Force bool + Layout string + Collected []string + CanFallback bool } func SyncSkills(opts SyncOptions) *SyncResult { @@ -280,16 +311,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 +343,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, }) + collected, err := resolveCollectedSkills(layout, opts.CollectedSkills, plan.OfficialSkills, previous, readable, 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 +362,58 @@ func SyncSkills(opts SyncOptions) *SyncResult { Added: plan.Added, SkippedDeleted: plan.SkippedDeleted, Force: opts.Force, + Layout: layout, + 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) + result.CanFallback = true + 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()) + if err := assembleSuiteLayout(layout, collected, 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, + CollectedSkills: stateCollectedSkills(layout, collected), UpdatedAt: opts.Now().UTC().Format(time.RFC3339), } if err := WriteState(state); err != nil { @@ -346,6 +425,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 +472,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 +501,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 +510,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 +532,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 +544,23 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy SkippedDeleted: []string{}, Detail: reason, Force: opts.Force, + Layout: LayoutSeparate, } } +func stateCollectedSkills(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 resultDetail(result *selfupdate.NpmResult) string { if result == nil { return "" diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go index fb8f117f..7ecf2a6a 100644 --- a/internal/skillscheck/sync_test.go +++ b/internal/skillscheck/sync_test.go @@ -205,6 +205,19 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) { assertStrings(t, got.SkippedDeleted, []string{}) } +func TestPlanSuiteInstallsAllNormalOfficialSkills(t *testing.T) { + got := PlanSync(SyncInput{ + Version: "1.0.33", + Layout: LayoutSuite, + OfficialSkills: []string{"lark-calendar", "lark-suite", "lark-mail"}, + LocalSkills: []string{"lark-suite"}, + }) + + assertStrings(t, got.OfficialSkills, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail"}) + assertStrings(t, got.SkippedDeleted, []string{}) +} + type fakeSkillsRunner struct { officialIndexOut string officialOut string @@ -216,8 +229,10 @@ type fakeSkillsRunner struct { globalErr error installErr error installAllErr error + installSuiteErr error installed [][]string installedAll int + installedSuite int listedIndex int listedOfficial int listedGlobalJSON int @@ -273,6 +288,43 @@ func globalSkillsJSONOutput(names ...string) string { return b.String() } +func globalSkillsJSONFromDir(dir string, names ...string) string { + var b strings.Builder + b.WriteString("[") + for i, name := range names { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, filepath.Join(dir, name)) + } + b.WriteString("]") + return b.String() +} + +func createTestSkill(t *testing.T, dir, name, description string) { + t.Helper() + skillDir := filepath.Join(dir, name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n\n# %s\n", name, description, name) + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func createTestSuiteSkill(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, suiteSkillName) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + content := "---\nname: lark-suite\ndescription: suite\n---\n\n## 能力路由\n\n" + suiteRoutesPlaceholder + "\n" + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult { f.listedIndex++ r := &selfupdate.NpmResult{} @@ -319,6 +371,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) @@ -361,11 +420,243 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) { assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"}) assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"}) assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"}) + if state.Layout != LayoutSeparate { + t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutSeparate) + } if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) { t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err) } } +func TestSyncSkills_SuiteAssemblesSubskillsAndRoutes(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + createTestSkill(t, dir, "lark-calendar", "Calendar operations") + createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting") + createTestSuiteSkill(t, dir) + + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"), + globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-shared", "lark-suite"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Layout: LayoutSuite, + Runner: runner, + Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) }, + }) + + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + if runner.installedSuite != 1 { + t.Fatalf("installedSuite = %d, want 1", runner.installedSuite) + } + if _, err := os.Stat(filepath.Join(dir, "lark-calendar")); !os.IsNotExist(err) { + t.Fatalf("top-level lark-calendar still exists or stat failed: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-calendar", "SKILL.md")); err != nil { + t.Fatalf("nested lark-calendar missing: %v", err) + } + suite, err := os.ReadFile(filepath.Join(dir, "lark-suite", "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(suite), "- lark-calendar: Calendar operations") { + t.Fatalf("suite routes were not generated from descriptions:\n%s", string(suite)) + } + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + if state.Layout != LayoutSuite { + t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutSuite) + } +} + +func TestSyncSkills_HybridCopiesSharedAndMovesCollected(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + createTestSkill(t, dir, "lark-calendar", "Calendar operations") + createTestSkill(t, dir, "lark-mail", "Mail operations") + createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting") + createTestSuiteSkill(t, dir) + + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-shared"), + globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-mail", "lark-shared", "lark-suite"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Layout: LayoutHybrid, + CollectedSkills: []string{"lark-calendar"}, + Runner: runner, + Now: time.Now, + }) + + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-calendar")); !os.IsNotExist(err) { + t.Fatalf("top-level lark-calendar still exists or stat failed: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-mail", "SKILL.md")); err != nil { + t.Fatalf("top-level lark-mail should remain: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-shared", "SKILL.md")); err != nil { + t.Fatalf("top-level lark-shared should remain in hybrid: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-shared", "SKILL.md")); err != nil { + t.Fatalf("nested lark-shared copy missing: %v", err) + } + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.CollectedSkills, []string{"lark-calendar"}) +} + +func TestSyncSkills_HybridCollectsNewOfficialSkillsIntoSuite(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{ + Version: "1.0.32", + Layout: LayoutHybrid, + OfficialSkills: []string{"lark-calendar", "lark-shared"}, + CollectedSkills: []string{"lark-calendar"}, + UpdatedAt: "2026-05-18T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + createTestSkill(t, dir, "lark-calendar", "Calendar operations") + createTestSkill(t, dir, "lark-new", "New operations") + createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting") + createTestSuiteSkill(t, dir) + + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-new", "lark-shared"), + globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-new", "lark-shared", "lark-suite"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Layout: LayoutHybrid, + CollectedSkills: []string{"lark-calendar"}, + Runner: runner, + Now: time.Now, + }) + + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-new", "SKILL.md")); err != nil { + t.Fatalf("new official skill should be collected into suite: %v", err) + } + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.CollectedSkills, []string{"lark-calendar", "lark-new"}) +} + +func TestSyncSkills_SuiteExcludesUserDeletedSubskillAndRebuildsRoutes(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := WriteState(SkillsState{ + Version: "1.0.32", + Layout: LayoutSuite, + OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-shared"}, + UpdatedAt: "2026-05-18T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + createTestSkill(t, dir, "lark-calendar", "Calendar operations") + createTestSkill(t, dir, "lark-new", "New operations") + createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting") + createTestSuiteSkill(t, dir) + + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new", "lark-shared"), + globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-new", "lark-shared", "lark-suite"), + } + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Layout: LayoutSuite, + Runner: runner, + Now: time.Now, + }) + + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-mail")); !os.IsNotExist(err) { + t.Fatalf("deleted subskill should not be regenerated, stat err: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "lark-suite", "references", "subskills", "lark-new", "SKILL.md")); err != nil { + t.Fatalf("new official skill should be regenerated into suite: %v", err) + } + state, readable, err := ReadState() + if err != nil || !readable { + t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err) + } + assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"}) +} + +func TestSyncSkills_SuiteInstallFailureDoesNotFallbackToSeparate(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"), + globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-shared"), + installErr: fmt.Errorf("ordinary install failed"), + } + + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Layout: LayoutSuite, + Runner: runner, + Now: time.Now, + }) + + if result.Action != "failed" || result.Err == nil { + t.Fatalf("SyncSkills() = %+v, want failed result", result) + } + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0; suite must not silently fallback to separate", runner.installedAll) + } + if state, readable, err := ReadState(); err != nil || readable || state != nil { + t.Fatalf("ReadState() = (%+v, %v, %v), want no successful state", state, readable, err) + } +} + +func TestSyncSkills_SuiteSpecialSourceFailureDoesNotWriteState(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + createTestSkill(t, dir, "lark-calendar", "Calendar operations") + createTestSkill(t, dir, "lark-shared", "Shared auth and troubleshooting") + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"), + globalJSONOut: globalSkillsJSONFromDir(dir, "lark-calendar", "lark-shared"), + installSuiteErr: fmt.Errorf("special source failed"), + } + + result := SyncSkills(SyncOptions{ + Version: "1.0.33", + Layout: LayoutSuite, + Runner: runner, + Now: time.Now, + }) + + if result.Action != "failed" || result.Err == nil { + t.Fatalf("SyncSkills() = %+v, want failed result", result) + } + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0; special source failure must not fallback to separate", runner.installedAll) + } + if state, readable, err := ReadState(); err != nil || readable || state != nil { + t.Fatalf("ReadState() = (%+v, %v, %v), want no successful state", state, readable, err) + } +} + func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) @@ -574,7 +865,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) { } } -func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) { +func TestSyncSkills_EmptyLocalJSONInstallsAllOfficialIncrementally(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ @@ -585,14 +876,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) } } diff --git a/isolated-skills/lark-suite/SKILL.md b/isolated-skills/lark-suite/SKILL.md new file mode 100644 index 00000000..3d9608b7 --- /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` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。 + +多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。 + +## 能力路由 + +根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。 + + diff --git a/scripts/check-skill-wire-vocab.sh b/scripts/check-skill-wire-vocab.sh index 2aaecd0b..fa1284e1 100755 --- a/scripts/check-skill-wire-vocab.sh +++ b/scripts/check-skill-wire-vocab.sh @@ -3,9 +3,9 @@ # with a skills/ grep sweep in the same PR. set -euo pipefail PATTERN='"type"\s*:\s*"(auth_error|api_error|infra_error|missing_scope|command_denied|external_provider)"' -if git grep -E "$PATTERN" skills/ >/dev/null 2>&1; then - echo "[WIRE-VOCAB-DRIFT] skills/ contains legacy wire strings — see spec §12.3" >&2 - git grep -nE "$PATTERN" skills/ >&2 +if git grep -E "$PATTERN" -- skills/ isolated-skills/ >/dev/null 2>&1; then + echo "[WIRE-VOCAB-DRIFT] skills/ or isolated-skills/ contains legacy wire strings — see spec §12.3" >&2 + git grep -nE "$PATTERN" -- skills/ isolated-skills/ >&2 exit 1 fi echo "skill wire-vocab clean." diff --git a/scripts/generate-lark-suite-from-descriptions.js b/scripts/generate-lark-suite-from-descriptions.js new file mode 100644 index 00000000..cdef1347 --- /dev/null +++ b/scripts/generate-lark-suite-from-descriptions.js @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, ".."); +const skillsDir = path.join(repoRoot, "skills"); +const templatePath = path.join(repoRoot, "isolated-skills", "lark-suite", "SKILL.md"); +const outPath = path.join(repoRoot, "lark-suite.generated-from-descriptions.SKILL.md"); +const statsPath = path.join(repoRoot, "lark-suite.generated-from-descriptions.stats.json"); + +function read(file) { + return fs.readFileSync(file, "utf8"); +} + +function parseFrontmatterDescription(skillPath) { + const text = read(skillPath); + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + throw new Error(`missing frontmatter: ${skillPath}`); + } + + const lines = match[1].split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const desc = line.match(/^description:\s*(.*)$/); + if (!desc) continue; + const value = desc[1].trim(); + if (value === ">" || value === "|") { + return parseIndentedBlock(lines, i + 1, value); + } + return unquoteYamlScalar(value); + } + throw new Error(`missing frontmatter description: ${skillPath}`); +} + +function parseIndentedBlock(lines, start, style) { + const block = []; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) { + block.push(""); + continue; + } + if (!line.startsWith(" ")) break; + block.push(line.replace(/^ ?/, "")); + } + if (style === "|") { + return block.join("\n").trim(); + } + return block.map((line) => line.trim()).filter(Boolean).join(" "); +} + +function unquoteYamlScalar(value) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function approxTokens(text) { + // Crude but stable enough for comparing generated variants. + return Math.ceil(Array.from(text).length / 1.7); +} + +function collectDescriptions() { + const descriptions = new Map(); + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (!name.startsWith("lark-") || name === "lark-suite") continue; + const skillPath = path.join(skillsDir, name, "SKILL.md"); + if (!fs.existsSync(skillPath)) continue; + descriptions.set(name, parseFrontmatterDescription(skillPath)); + } + return descriptions; +} + +function generate(template, descriptions) { + const used = Array.from(descriptions.keys()).sort(); + const routes = used.map((skillName) => { + const description = descriptions.get(skillName); + return `- ${skillName}: ${description}`; + }); + if (!template.includes("")) { + throw new Error("missing route placeholder in lark-suite template"); + } + + return { + text: template.replace("", routes.join("\n")), + used, + }; +} + +function main() { + const template = read(templatePath); + const descriptions = collectDescriptions(); + const { text, used } = generate(template, descriptions); + fs.writeFileSync(outPath, text.endsWith("\n") ? text : `${text}\n`); + + const stats = { + generated_file: outPath, + template_file: templatePath, + rule: "Fill isolated-skills/lark-suite/SKILL.md route placeholder from installed skill descriptions.", + skill_count: used.length, + generated_bytes: Buffer.byteLength(text), + generated_chars: Array.from(text).length, + generated_approx_tokens: approxTokens(text), + template_bytes: Buffer.byteLength(template), + template_chars: Array.from(template).length, + template_approx_tokens: approxTokens(template), + route_entries: used, + entry_token_stats: used + .map((skill) => { + const description = descriptions.get(skill); + return [skill, approxTokens(description), Array.from(description).length]; + }) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), + }; + fs.writeFileSync(statsPath, `${JSON.stringify(stats, null, 2)}\n`); + console.log(`Generated ${path.relative(repoRoot, outPath)} from ${path.relative(repoRoot, templatePath)}`); + console.log(`Updated ${path.relative(repoRoot, statsPath)}`); +} + +main(); diff --git a/scripts/install-wizard.js b/scripts/install-wizard.js index 4bc76f5d..e4c11883 100644 --- a/scripts/install-wizard.js +++ b/scripts/install-wizard.js @@ -197,20 +197,33 @@ function getExistingAppId(binPath) { /** Parse --lang from process.argv, returns "zh", "en", or null. */ function parseLangArg() { + const val = parseStringArg("--lang"); + if (val === "zh" || val === "en") return val; + return null; +} + +function parseStringArg(name) { const args = process.argv.slice(2); for (let i = 0; i < args.length; i++) { - if (args[i] === "--lang" && args[i + 1]) { - const val = args[i + 1].toLowerCase(); - if (val === "zh" || val === "en") return val; + if (args[i] === name && args[i + 1]) { + return args[i + 1]; } - if (args[i].startsWith("--lang=")) { - const val = args[i].split("=")[1].toLowerCase(); - if (val === "zh" || val === "en") return val; + if (args[i].startsWith(`${name}=`)) { + return args[i].slice(name.length + 1); } } return null; } +function skillsUpdateArgs() { + const args = ["update"]; + const layout = parseStringArg("--skills-layout"); + const collected = parseStringArg("--collected-skills"); + if (layout) args.push("--skills-layout", layout); + if (collected) args.push("--collected-skills", collected); + return args; +} + // --------------------------------------------------------------------------- // Steps // --------------------------------------------------------------------------- @@ -270,8 +283,12 @@ async function stepInstallSkills(msg) { const s = p.spinner(); s.start(msg.step2Spinner); try { - if (await skillsAlreadyInstalled()) { - s.stop(msg.step2Skip); + const larkCli = whichLarkCli(); + if (larkCli) { + await runSilentAsync(larkCli, skillsUpdateArgs(), { + timeout: 120000, + }); + s.stop(msg.step2Done); return; } try { diff --git a/scripts/skill-format-check/README.md b/scripts/skill-format-check/README.md index 04a00b4e..b02a625a 100644 --- a/scripts/skill-format-check/README.md +++ b/scripts/skill-format-check/README.md @@ -1,6 +1,6 @@ # Skill Format Check -This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` directory. +This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` and `../../isolated-skills` directories. ## Purpose @@ -13,7 +13,7 @@ The `index.js` script ensures that all `SKILL.md` files conform to the standard ## Usage -This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` directory. +This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` or `isolated-skills/` directory. To run the check manually from the root of the repository, execute: diff --git a/scripts/skill-format-check/index.js b/scripts/skill-format-check/index.js index 169ff3a7..1376ef43 100644 --- a/scripts/skill-format-check/index.js +++ b/scripts/skill-format-check/index.js @@ -6,24 +6,27 @@ const path = require('path'); // Allow passing a target directory as the first argument. // If provided, resolve against process.cwd() so it behaves as the user expects. -// If not provided, default to '../../skills' relative to this script's directory. +// If not provided, default to the official skill directories. const targetDirArg = process.argv[2]; -const SKILLS_DIR = targetDirArg - ? path.resolve(process.cwd(), targetDirArg) - : path.resolve(__dirname, '../../skills'); +const TARGET_DIRS = targetDirArg + ? [path.resolve(process.cwd(), targetDirArg)] + : [ + path.resolve(__dirname, '../../skills'), + path.resolve(__dirname, '../../isolated-skills'), + ]; -function checkSkillFormat() { - console.log(`Checking skill format in ${SKILLS_DIR}...`); +function checkSkillFormatInDir(skillsDir) { + console.log(`Checking skill format in ${skillsDir}...`); - if (!fs.existsSync(SKILLS_DIR)) { - console.error('Skills directory not found:', SKILLS_DIR); + if (!fs.existsSync(skillsDir)) { + console.error('Skills directory not found:', skillsDir); process.exit(1); } let skills; try { skills = fs - .readdirSync(SKILLS_DIR, { withFileTypes: true }) + .readdirSync(skillsDir, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => entry.name); } catch (err) { @@ -40,7 +43,7 @@ function checkSkillFormat() { return; } - const skillPath = path.join(SKILLS_DIR, skill); + const skillPath = path.join(skillsDir, skill); const skillFile = path.join(skillPath, 'SKILL.md'); if (!fs.existsSync(skillFile)) { @@ -88,12 +91,18 @@ function checkSkillFormat() { } }); - if (hasErrors) { + return !hasErrors; +} + +function checkSkillFormat() { + const ok = TARGET_DIRS.map(checkSkillFormatInDir).every(Boolean); + + if (!ok) { console.error('\n❌ Skill format check failed. Please fix the errors above.'); process.exit(1); - } else { - console.log('\n✅ Skill format check passed!'); } + + console.log('\n✅ Skill format check passed!'); } checkSkillFormat();