feat: support separate and suite skill layouts (#2211)

This commit is contained in:
guokexin.02
2026-08-18 20:59:31 +08:00
committed by GitHub
parent 679ebd5289
commit e0867e6ebb
20 changed files with 1634 additions and 996 deletions
+22
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/skillscheck"
)
// composePendingNotice must surface a deprecated-command alias under the
@@ -45,6 +46,27 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
}
}
func TestComposePendingNoticeOfficialSkillsUnknown(t *testing.T) {
t.Cleanup(func() { skillscheck.SetPending(nil) })
skillscheck.SetPending(&skillscheck.StaleNotice{
Current: "1.0.21",
Target: "1.0.21",
OfficialUnknown: true,
})
got := composePendingNotice(nil)
entry, ok := got["skills"].(map[string]interface{})
if !ok {
t.Fatalf("missing skills notice: %#v", got)
}
if entry["official_unknown"] != true {
t.Fatalf("skills notice = %#v, want official_unknown=true", entry)
}
if entry["command"] != "lark-cli update" {
t.Fatalf("skills notice command = %v, want lark-cli update", entry["command"])
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
+5 -1
View File
@@ -194,12 +194,16 @@ func composePendingNotice(plan *surface.Plan) map[string]interface{} {
}
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
entry := map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
if stale.OfficialUnknown {
entry["official_unknown"] = true
}
notice["skills"] = entry
}
}
if dep := deprecation.GetPending(); dep != nil {
+96 -20
View File
@@ -88,10 +88,11 @@ 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
}
// NewCmdUpdate creates the update command.
@@ -109,7 +110,9 @@ Detects the installation method automatically:
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
Use --check to only check for updates without installing.
The skill name "lark-suite" is reserved for CLI-managed suite layout.`,
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
@@ -118,6 +121,7 @@ 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 suite")
cmdutil.SetRisk(cmd, "high-risk-write")
return cmd
@@ -125,6 +129,16 @@ Use --check to only check for updates without installing.`,
func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil {
return reportError(opts, io, "validation",
errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or suite").WithParam("--skills-layout"))
}
if opts.Check && strings.TrimSpace(opts.SkillsLayout) != "" {
return reportError(opts, io, "validation",
errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout cannot be used with --check").
WithParam("--skills-layout").
WithHint("Remove --skills-layout when using --check."))
}
cur := currentVersion()
updater := newUpdater()
// Brand only steers skills sync. updateRun skips that resolution in --check,
@@ -152,7 +166,10 @@ 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)
if err := reportSkillsFailure(opts, io, skillsResult); err != nil {
return err
}
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -195,10 +212,18 @@ func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) core.LarkBrand
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
return reportErrorWithFields(opts, io, errType, typedErr, nil)
}
func reportErrorWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError, fields map[string]interface{}) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
})
out := make(map[string]interface{}, len(fields)+2)
for key, value := range fields {
out[key] = value
}
out["ok"] = false
out["error"] = map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message}
output.PrintJson(io.Out, out)
return output.ErrBare(output.ExitCodeOf(typedErr))
}
return typedErr
@@ -229,8 +254,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)
reason := detect.ManualReason()
if opts.JSON {
out := map[string]interface{}{
@@ -240,6 +264,9 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsResult(out, skillsResult)
if err := reportSkillsFailureWithFields(opts, io, skillsResult, out); err != nil {
return err
}
output.PrintJson(io.Out, out)
return nil
}
@@ -252,6 +279,9 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
if err := reportSkillsFailure(opts, io, skillsResult); err != nil {
return err
}
emitSkillsTextHints(io, skillsResult)
return nil
}
@@ -319,7 +349,21 @@ func doAutoUpdate(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)
if skillsResult != nil && skillsResult.Err != nil {
fields := map[string]interface{}{
"previous_version": cur, "current_version": latest,
"latest_version": latest, "action": "updated",
"message": fmt.Sprintf("lark-cli updated from %s to %s, but skills update failed", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsResult(fields, skillsResult)
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "\n%s lark-cli binary updated from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
}
return reportSkillsFailureWithFields(opts, io, skillsResult, fields)
}
if opts.JSON {
result := map[string]interface{}{
@@ -366,14 +410,18 @@ func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) str
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 {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult {
layout, _ := skillscheck.ParseLayout(requestedLayout)
if !force {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
if state, ok, err := skillscheck.ReadState(); err == nil && ok && normalizeVersion(state.Version) == normalizeVersion(stateVersion) {
if !state.OfficialSkillsUnknown && (layout == "" || skillscheck.EffectiveLayout(state) == layout) {
return nil
}
}
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
Layout: layout,
Force: force,
Runner: updater,
})
@@ -383,6 +431,20 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
return result
}
func reportSkillsFailure(opts *UpdateOptions, io *cmdutil.IOStreams, result *skillscheck.SyncResult) error {
return reportSkillsFailureWithFields(opts, io, result, nil)
}
func reportSkillsFailureWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, result *skillscheck.SyncResult, fields map[string]interface{}) error {
if result == nil || result.Err == nil {
return nil
}
typedErr := errs.NewInternalError(errs.SubtypeUnknown, "skills update failed: %s", result.Err).
WithHint("retry with `lark-cli update --force`").
WithCause(result.Err)
return reportErrorWithFields(opts, io, "skills_update_error", typedErr, fields)
}
// 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
@@ -418,9 +480,11 @@ func applySkillsStatus(env map[string]interface{}, target string) {
status := map[string]interface{}{
"current": state.Version,
"target": target,
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target) && !state.OfficialSkillsUnknown,
}
if len(state.OfficialSkills) > 0 {
if state.OfficialSkillsUnknown {
status["official_unknown"] = true
} else if len(state.OfficialSkills) > 0 {
status["official"] = len(state.OfficialSkills)
}
if len(state.UpdatedSkills) > 0 {
@@ -429,6 +493,7 @@ func applySkillsStatus(env map[string]interface{}, target string) {
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
status["layout"] = skillscheck.EffectiveLayout(state)
env["skills_status"] = status
}
@@ -443,15 +508,23 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
default:
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
if r.Warning != "" {
env["skills_warning"] = r.Warning
}
}
}
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
summary := map[string]interface{}{
"official": len(r.Official),
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
"layout": r.Layout,
}
if r.OfficialUnknown {
summary["official_unknown"] = true
} else {
summary["official"] = len(r.Official)
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
@@ -468,10 +541,13 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
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")
case r.Warning != "":
fmt.Fprintf(io.ErrOut, "%s Skills updated using %s layout\n", symOK(), r.Layout)
fmt.Fprintf(io.ErrOut, "%s %s\n", symWarn(), r.Warning)
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 using %s layout: restored all %d official skills\n", symOK(), r.Layout, len(r.Official))
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 using %s layout: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), r.Layout, len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
+197 -74
View File
@@ -89,7 +89,7 @@ func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar","type":"archive","url":"./lark-calendar.tar.gz","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"name":"lark-mail","type":"archive","url":"./lark-mail.tar.gz","digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}`)
return r
}
}
@@ -110,6 +110,22 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func skillsFailureUpdater(detect selfupdate.DetectResult) *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return detect }
u.NpmInstallOverride = func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{Err: fmt.Errorf("index unavailable")}
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{Err: fmt.Errorf("exit status 127")}
r.Stderr.WriteString("npx: command not found")
return r
}
return u
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
@@ -1058,45 +1074,22 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
return r
}
return u
return skillsFailureUpdater(selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
if err == nil {
t.Fatal("expected skills sync failure")
}
out := stdout.String()
// CLI update should still succeed (ok:true)
if !strings.Contains(out, `"ok": true`) {
t.Errorf("expected ok:true despite skills failure, got: %s", out)
}
if !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected action:updated despite skills failure, got: %s", out)
}
// Should have skills_warning with detail
if !strings.Contains(out, "skills_warning") {
t.Errorf("expected skills_warning in output, got: %s", out)
}
if !strings.Contains(out, "skills_summary") {
t.Errorf("expected skills_summary in output, got: %s", out)
for _, want := range []string{
`"ok": false`, `"type": "skills_update_error"`, `"action": "updated"`,
`"previous_version": "1.0.0"`, `"current_version": "2.0.0"`, `"skills_action": "failed"`,
} {
if !strings.Contains(out, want) {
t.Errorf("expected %s in partial update output, got: %s", want, out)
}
}
}
@@ -1115,42 +1108,79 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
return r
}
return u
return skillsFailureUpdater(selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true})
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
if err == nil || !strings.Contains(err.Error(), "skills update failed") {
t.Fatalf("error = %v, want skills update failure", err)
}
if !strings.Contains(stderr.String(), "lark-cli binary updated from 1.0.0 to 2.0.0") {
t.Errorf("must report the completed binary update before the skills error: %s", stderr.String())
}
if strings.Contains(stderr.String(), "Successfully updated") {
t.Errorf("must not report full success after skills failure: %s", stderr.String())
}
}
func TestUpdateManual_SkillsFail_JSONStillReportsManualUpdate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
return skillsFailureUpdater(selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
}
defer func() { newUpdater = origNew }()
if err := cmd.Execute(); err == nil {
t.Fatal("expected skills sync failure")
}
out := stdout.String()
for _, want := range []string{
`"ok": false`, `"type": "skills_update_error"`, `"action": "manual_required"`,
`"previous_version": "1.0.0"`, `"latest_version": "2.0.0"`, `"skills_action": "failed"`,
"releases/tag/v2.0.0",
} {
if !strings.Contains(out, want) {
t.Errorf("expected %s in manual update output, got: %s", want, out)
}
}
}
func TestUpdateManual_SkillsFail_HumanStillReportsManualUpdate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
return skillsFailureUpdater(selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"})
}
defer func() { newUpdater = origNew }()
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "skills update failed") {
t.Fatalf("error = %v, want skills update failure", err)
}
out := stderr.String()
// CLI update should still show success
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected CLI success message, got: %s", out)
}
// Skills warning should be shown
if !strings.Contains(out, "Skills update failed") {
t.Errorf("expected skills failure warning, got: %s", out)
}
if !strings.Contains(out, "lark-cli update --force") {
t.Errorf("expected force retry hint, got: %s", out)
if !strings.Contains(out, "Automatic update unavailable") || !strings.Contains(out, "releases/tag/v2.0.0") {
t.Errorf("must report manual update instructions before the skills error: %s", out)
}
}
@@ -1171,7 +1201,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, "")
if got != nil {
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
@@ -1180,6 +1210,99 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
}
}
func TestRunSkillsAndState_RequestedLayoutBypassesVersionDedup(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)
}
originalSync := syncSkills
defer func() { syncSkills = originalSync }()
called := false
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
called = true
if opts.Layout != skillscheck.LayoutSuite {
t.Fatalf("layout = %q, want suite", opts.Layout)
}
return &skillscheck.SyncResult{Action: "synced", Layout: skillscheck.LayoutSuite}
}
got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "suite")
if !called || got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, called = %v", got, called)
}
}
func TestRunSkillsAndState_UnknownOfficialSkillsBypassesVersionDedup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.21",
Layout: skillscheck.LayoutSeparate,
OfficialSkillsUnknown: true,
}); err != nil {
t.Fatal(err)
}
originalSync := syncSkills
defer func() { syncSkills = originalSync }()
called := false
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
called = true
return &skillscheck.SyncResult{Action: "synced", Layout: skillscheck.LayoutSeparate}
}
got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "")
if !called || got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, called = %v", got, called)
}
}
func TestSkillsSummaryMarksUnknownOfficialSkills(t *testing.T) {
summary := skillsSummary(&skillscheck.SyncResult{
Layout: skillscheck.LayoutSeparate,
OfficialUnknown: true,
})
if summary["official_unknown"] != true {
t.Fatalf("summary = %+v, want official_unknown=true", summary)
}
if _, ok := summary["official"]; ok {
t.Fatalf("summary = %+v, official count must be omitted when unknown", summary)
}
}
func TestUpdateRejectsInvalidSkillsLayout(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--skills-layout", "hybrid"})
err := cmd.Execute()
problem, ok := errs.ProblemOf(err)
var validation *errs.ValidationError
if !ok || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validation) || validation.Param != "--skills-layout" {
t.Fatalf("problem = %+v, ok = %v", problem, ok)
}
}
func TestUpdateCheckRejectsSkillsLayoutBeforeNetwork(t *testing.T) {
f, _, _ := newTestFactory(t)
origFetch := fetchLatest
fetched := false
fetchLatest = func() (string, error) {
fetched = true
return "2.0.0", nil
}
t.Cleanup(func() { fetchLatest = origFetch })
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--check", "--skills-layout", "suite"})
err := cmd.Execute()
problem, ok := errs.ProblemOf(err)
var validation *errs.ValidationError
if !ok || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validation) || validation.Param != "--skills-layout" {
t.Fatalf("problem = %+v, ok = %v", problem, ok)
}
if fetched {
t.Fatal("fetchLatest was called before incompatible flags were rejected")
}
}
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 {
@@ -1193,7 +1316,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, "")
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
@@ -1208,7 +1331,7 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "")
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
@@ -1234,7 +1357,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, "")
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
@@ -1527,7 +1650,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, "")
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}
@@ -1626,9 +1749,9 @@ func TestPrepareLiveSkillsIntegration(t *testing.T) {
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// seedLiveSkillsGlobal verifies the real npx skills CLI and the v0.2 source are
// reachable, installs lark-calendar into the isolated global skills dir, and
// returns the parsed global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
@@ -1641,10 +1764,10 @@ func seedLiveSkillsGlobal(t *testing.T) []string {
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn/lark-cli/skills/regular", "--list").Run(); err != nil {
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn/lark-cli/skills/regular", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
+54 -40
View File
@@ -50,7 +50,7 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
// officialSkillsIndexURL overrides the brand-derived skills index URL in
// officialSkillsIndexURL overrides the source-derived skills index URL in
// tests; empty in production.
officialSkillsIndexURL = ""
)
@@ -107,13 +107,14 @@ type Updater struct {
// Brand selects the skills index/source endpoints (zero value = feishu).
Brand core.LarkBrand
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
SkillsCommandInDirOverride func(dir string, args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
// backupCreated is set to true by PrepareSelfReplace (Windows) when the
// running binary is successfully renamed to .old. Used by
@@ -135,17 +136,26 @@ type Updater struct {
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// skillsIndexURL returns the brand's well-known skills index URL.
func (u *Updater) skillsIndexURL() string {
// skillsIndexURL returns the Agent Skills v0.2 index under source.
func skillsIndexURL(source string) string {
if officialSkillsIndexURL != "" {
return officialSkillsIndexURL
}
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
return strings.TrimRight(source, "/") + "/.well-known/agent-skills/index.json"
}
// skillsSource returns the brand's skills source host for `npx skills add`.
func (u *Updater) skillsSource() string {
return core.ResolveEndpoints(u.Brand).Open
// SkillsSources returns the brand-specific v0.2 source first, followed by the
// other brand's source. Falling back must not change the configured brand.
func (u *Updater) SkillsSources() []string {
primary := core.ParseBrand(string(u.Brand))
secondary := core.BrandLark
if primary == core.BrandLark {
secondary = core.BrandFeishu
}
return []string{
core.ResolveEndpoints(primary).Open + "/lark-cli/skills/regular",
core.ResolveEndpoints(secondary).Open + "/lark-cli/skills/regular",
}
}
// DetectInstallMethod determines how the CLI was installed and whether the
@@ -268,7 +278,7 @@ func (u *Updater) RunPnpmInstall(version string) *NpmResult {
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
func (u *Updater) FetchSkillsIndex(source string) *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
}
@@ -277,7 +287,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, skillsIndexURL(source), nil)
if err != nil {
r.Err = err
return r
@@ -315,14 +325,6 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial(u.skillsSource())
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
return r
}
func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
@@ -331,30 +333,34 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall(u.skillsSource(), nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
return r
func (u *Updater) InstallSkills(source string, nameList []string) *NpmResult {
return u.runSkillsInstall(source, nameList)
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd(u.skillsSource())
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
return r
func (u *Updater) InstallAllSkills(source string) *NpmResult {
return u.runSkillsAdd(source)
}
func (u *Updater) StageSuite(source, dir string) *NpmResult {
suiteSource := strings.TrimSuffix(strings.TrimRight(source, "/"), "/regular") + "/isolated"
return u.runSkillsCommandInDir(dir, "-y", "skills", "add", suiteSource, "-s", "lark-suite", "-y")
}
func (u *Updater) InstallLocalSuite(path string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", path, "-s", "lark-suite", "-g", "-y")
}
func (u *Updater) RemoveGlobalSkills(names []string) *NpmResult {
args := []string{"-y", "skills", "remove", "-g", "-s"}
args = append(args, names...)
args = append(args, "-y")
return u.runSkillsCommand(args...)
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
}
func (u *Updater) runSkillsListGlobal() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g")
}
@@ -386,6 +392,13 @@ func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
return u.runSkillsCommandInDir("", args...)
}
func (u *Updater) runSkillsCommandInDir(dir string, args ...string) *NpmResult {
if u.SkillsCommandInDirOverride != nil {
return u.SkillsCommandInDirOverride(dir, args...)
}
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
@@ -400,6 +413,7 @@ func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd.Dir = dir
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
+42 -46
View File
@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
@@ -182,11 +183,11 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
want string
}{
{
name: "list official primary",
name: "stage suite",
run: func(u *Updater) *NpmResult {
return u.runSkillsListOfficial("https://open.feishu.cn")
return u.StageSuite("https://open.feishu.cn/lark-cli/skills/regular", ".")
},
want: "-y skills add https://open.feishu.cn --list",
want: "-y skills add https://open.feishu.cn/lark-cli/skills/isolated -s lark-suite -y",
},
{
name: "list global",
@@ -239,6 +240,32 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}
func TestStageSuiteUsesProvidedWorkingDirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
binDir := t.TempDir()
stageDir := t.TempDir()
logPath := filepath.Join(binDir, "pwd.log")
script := filepath.Join(binDir, "npx")
if err := os.WriteFile(script, []byte(fmt.Sprintf("#!/bin/sh\npwd > %q\n", logPath)), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
result := New().StageSuite("https://open.feishu.cn/lark-cli/skills/regular", stageDir)
if result.Err != nil {
t.Fatalf("StageSuite() err = %v, want nil", result.Err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if got := filepath.Clean(strings.TrimSpace(string(raw))); got != filepath.Clean(stageDir) {
t.Fatalf("working directory = %q, want %q", got, stageDir)
}
}
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
@@ -249,7 +276,7 @@ func TestListOfficialSkillsIndexSuccess(t *testing.T) {
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
result := New().FetchSkillsIndex("https://open.feishu.cn/lark-cli")
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
@@ -268,7 +295,7 @@ func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
result := New().FetchSkillsIndex("https://open.feishu.cn/lark-cli")
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
}
@@ -284,7 +311,7 @@ func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
result := New().FetchSkillsIndex("https://open.feishu.cn/lark-cli")
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
}
@@ -309,7 +336,7 @@ func TestListOfficialSkillsIndexTimeout(t *testing.T) {
skillsIndexFetchTimeout = oldTimeout
})
result := New().ListOfficialSkillsIndex()
result := New().FetchSkillsIndex("https://open.feishu.cn/lark-cli")
var netErr net.Error
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
@@ -326,7 +353,7 @@ func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
result := New().FetchSkillsIndex("https://open.feishu.cn/lark-cli")
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
}
@@ -337,7 +364,7 @@ func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
r := &NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
return r
}}).ListOfficialSkillsIndex()
}}).FetchSkillsIndex("https://open.feishu.cn/lark-cli")
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
@@ -346,33 +373,6 @@ func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
r.Err = fmt.Errorf("primary failed")
return r
}
r.Stdout.WriteString("lark-calendar\n")
return r
},
}
result := updater.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
}
if !strings.Contains(called[1], "larksuite/cli --list") {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
@@ -519,20 +519,16 @@ func TestDetectInstallMethod_Caches(t *testing.T) {
func TestSkillsBrandHosts(t *testing.T) {
cases := []struct {
brand core.LarkBrand
wantIndex string
wantSource string
brand core.LarkBrand
wantSources []string
}{
{core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"},
{core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"},
{core.BrandFeishu, []string{"https://open.feishu.cn/lark-cli/skills/regular", "https://open.larksuite.com/lark-cli/skills/regular"}},
{core.BrandLark, []string{"https://open.larksuite.com/lark-cli/skills/regular", "https://open.feishu.cn/lark-cli/skills/regular"}},
}
for _, c := range cases {
u := &Updater{Brand: c.brand}
if got := u.skillsIndexURL(); got != c.wantIndex {
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
}
if got := u.skillsSource(); got != c.wantSource {
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
if got := u.SkillsSources(); !reflect.DeepEqual(got, c.wantSources) {
t.Errorf("brand %q: SkillsSources = %q, want %q", c.brand, got, c.wantSources)
}
}
}
+8 -6
View File
@@ -6,7 +6,8 @@ package skillscheck
import "strings"
// Init runs the synchronous skills version check. Stores a StaleNotice when
// the local skills state records a version that does not match currentVersion.
// the local skills state records a version that does not match currentVersion,
// or the last sync could not determine the complete official Skill set.
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local state file read.
//
@@ -17,15 +18,16 @@ func Init(currentVersion string) {
if shouldSkip(currentVersion) {
return
}
version, ok := ReadSyncedVersion()
if !ok {
state, ok, err := ReadState()
if err != nil || !ok || state.Version == "" {
return
}
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
if strings.TrimPrefix(strings.TrimPrefix(state.Version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") && !state.OfficialSkillsUnknown {
return
}
SetPending(&StaleNotice{
Current: version,
Target: currentVersion,
Current: state.Version,
Target: currentVersion,
OfficialUnknown: state.OfficialSkillsUnknown,
})
}
+17
View File
@@ -51,6 +51,23 @@ func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
}
}
func TestInit_OfficialSkillsUnknown_NoticeAtSameVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21", OfficialSkillsUnknown: true}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
got := GetPending()
if got == nil {
t.Fatal("GetPending() = nil, want notice for unknown official Skill set")
}
if got.Current != "1.0.21" || got.Target != "1.0.21" || !got.OfficialUnknown {
t.Errorf("notice = %+v, want same-version official_unknown notice", got)
}
}
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
+216
View File
@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
type Layout string
const (
LayoutSeparate Layout = "separate"
LayoutSuite Layout = "suite"
suiteDescriptionPrefix = "description: 飞书/Lark 聚合能力入口:管理飞书/Lark 产品能力("
suiteDescriptionSuffix = "等)。"
)
func ParseLayout(value string) (Layout, error) {
layout := Layout(strings.TrimSpace(value))
switch layout {
case "", LayoutSeparate, LayoutSuite:
return layout, nil
default:
return "", fmt.Errorf("skills layout must be separate or suite")
}
}
func EffectiveLayout(state *SkillsState) Layout {
if state != nil && state.Layout == LayoutSuite {
return LayoutSuite
}
return LayoutSeparate
}
func ResolveLayout(requested Layout, state *SkillsState, readable bool) (Layout, error) {
if requested != "" {
return ParseLayout(string(requested))
}
if readable {
return EffectiveLayout(state), nil
}
return LayoutSeparate, nil
}
func syncSuite(runner SkillsRunner, source string, plan SyncPlan, installed []installedSkill) error {
installedSeparate := installedOfficialNames(installed, plan.CleanupOfficial)
if len(plan.ToUpdate) == 0 {
remove := append(installedSeparate, installedNameIfPresent(installed, "lark-suite")...)
return removeSkills(runner, remove)
}
stagingRoot, err := vfs.MkdirTemp("", "lark-cli-suite-")
if err != nil {
return fmt.Errorf("create suite staging directory: %w", err)
}
defer vfs.RemoveAll(stagingRoot)
stageResult := runner.StageSuite(source, stagingRoot)
if stageResult == nil || stageResult.Err != nil {
return fmt.Errorf("suite archive install failed: %s", resultDetail(stageResult))
}
suitePath := filepath.Join(stagingRoot, ".agents", "skills", "lark-suite")
if err := prepareSuite(suitePath, plan.OfficialSkills, plan.ToUpdate); err != nil {
return err
}
installResult := runner.InstallLocalSuite(suitePath)
if installResult == nil || installResult.Err != nil {
return fmt.Errorf("install cropped lark-suite failed: %s", resultDetail(installResult))
}
if err := removeSkills(runner, installedSeparate); err != nil {
return err
}
return nil
}
func prepareSuite(suitePath string, official, target []string) error {
referencesPath := filepath.Join(suitePath, "references")
archived, err := listDirectSubdirs(referencesPath)
if err != nil {
return fmt.Errorf("inspect suite archive: %w", err)
}
if err := assertSameSkillNames(archived, official, "suite archive"); err != nil {
return err
}
targetSet := toSet(target)
removed := []string{}
for _, name := range official {
if targetSet[name] {
continue
}
if err := vfs.RemoveAll(filepath.Join(referencesPath, name)); err != nil {
return fmt.Errorf("remove suite child %s: %w", name, err)
}
removed = append(removed, name)
}
skillPath := filepath.Join(suitePath, "SKILL.md")
raw, err := vfs.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("read suite SKILL.md: %w", err)
}
rendered, err := cropSuiteRoutes(string(raw), removed, target)
if err != nil {
return err
}
if err := vfs.WriteFile(skillPath, []byte(rendered), 0o644); err != nil {
return fmt.Errorf("write cropped suite SKILL.md: %w", err)
}
kept, err := listDirectSubdirs(referencesPath)
if err != nil {
return fmt.Errorf("verify cropped suite: %w", err)
}
return assertSameSkillNames(kept, target, "cropped suite")
}
func cropSuiteRoutes(content string, removed, target []string) (string, error) {
routeLine := func(name string) *regexp.Regexp {
return regexp.MustCompile(`(?m)^- ` + regexp.QuoteMeta(name) + `(?:[^\n]*)?:[^\n]*(?:\n|$)`)
}
for _, name := range removed {
line := routeLine(name)
if len(line.FindAllStringIndex(content, -1)) != 1 {
return "", fmt.Errorf("suite route for %s is missing or duplicated", name)
}
content = line.ReplaceAllString(content, "")
}
for _, name := range target {
if len(routeLine(name).FindAllStringIndex(content, -1)) != 1 {
return "", fmt.Errorf("cropped suite route for %s is missing or duplicated", name)
}
}
keywords := suiteKeywords(content)
start := strings.Index(content, suiteDescriptionPrefix)
if start < 0 {
return "", fmt.Errorf("suite description prefix is missing")
}
valueStart := start + len(suiteDescriptionPrefix)
valueEndOffset := strings.Index(content[valueStart:], suiteDescriptionSuffix)
if valueEndOffset < 0 {
return "", fmt.Errorf("suite description keyword suffix is missing")
}
valueEnd := valueStart + valueEndOffset
content = content[:valueStart] + strings.Join(keywords, "、") + content[valueEnd:]
return content, nil
}
func suiteKeywords(content string) []string {
routeLine := regexp.MustCompile(`(?m)^- [^\n]+(?:([^\n]*))?:`)
seen := map[string]bool{}
keywords := []string{}
for _, match := range routeLine.FindAllStringSubmatch(content, -1) {
if len(match) < 2 || match[1] == "" {
continue
}
for _, keyword := range strings.Split(match[1], "、") {
keyword = strings.TrimSpace(keyword)
if keyword != "" && !seen[keyword] {
seen[keyword] = true
keywords = append(keywords, keyword)
}
}
}
return keywords
}
func installedOfficialNames(installed []installedSkill, official []string) []string {
officialSet := toSet(official)
names := []string{}
for _, skill := range installed {
if officialSet[skill.Name] {
names = append(names, skill.Name)
}
}
sort.Strings(names)
return names
}
func installedNameIfPresent(installed []installedSkill, name string) []string {
if hasInstalledSkill(installed, name) {
return []string{name}
}
return nil
}
func removeSkills(runner SkillsRunner, names []string) error {
if len(names) == 0 {
return nil
}
result := runner.RemoveGlobalSkills(uniqueSorted(names))
if result == nil || result.Err != nil {
return fmt.Errorf("remove stale official skills failed: %s", resultDetail(result))
}
return nil
}
func assertSameSkillNames(got, want []string, label string) error {
got = uniqueSorted(got)
want = uniqueSorted(want)
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
return fmt.Errorf("%s child Skill list mismatch: got %v, want %v", label, got, want)
}
return nil
}
+11 -7
View File
@@ -12,21 +12,25 @@ import (
"sync/atomic"
)
// StaleNotice signals that the locally synced skills version does not
// match the running binary. Current is the last successfully synced
// version (always non-empty — Init no longer emits a notice on cold
// start). Target is the running binary version. Mirrors
// StaleNotice signals that the locally synced skills need attention because
// their version is stale or their official completeness is unknown. Current
// is the last successfully synced version (always non-empty — Init does not
// emit a notice on cold start). Target is the running binary version. Mirrors
// internal/update.UpdateInfo's pending-notice pattern.
type StaleNotice struct {
Current string `json:"current"`
Target string `json:"target"`
Current string `json:"current"`
Target string `json:"target"`
OfficialUnknown bool `json:"official_unknown,omitempty"`
}
// Message returns a single-line, AI-agent-parseable description of the
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
// non-empty because Init only emits a StaleNotice for the drift case.
// non-empty because Init only emits a StaleNotice after a completed sync.
func (s *StaleNotice) Message() string {
if s.OfficialUnknown {
return "lark-cli skills were installed from a fallback source; official completeness is unknown, run: lark-cli update"
}
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",
s.Current, s.Target,
+5
View File
@@ -19,6 +19,11 @@ func TestStaleNotice_Message(t *testing.T) {
StaleNotice{Current: "1.0.20", Target: "1.0.21"},
"lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update",
},
{
"official skills unknown",
StaleNotice{Current: "1.0.21", Target: "1.0.21", OfficialUnknown: true},
"lark-cli skills were installed from a fallback source; official completeness is unknown, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+8 -6
View File
@@ -22,12 +22,14 @@ const (
var ErrUnreadableState = errors.New("skills state is unreadable")
type SkillsState struct {
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
Version string `json:"version"`
Layout Layout `json:"layout,omitempty"`
OfficialSkills []string `json:"official_skills"`
OfficialSkillsUnknown bool `json:"official_skills_unknown,omitempty"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
}
func statePath() string {
+10
View File
@@ -32,6 +32,7 @@ func TestReadState_Valid(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
want := SkillsState{
Version: "1.2.3",
Layout: LayoutSuite,
OfficialSkills: []string{"lark-doc", "lark-im"},
UpdatedSkills: []string{"lark-doc"},
AddedOfficialSkills: []string{"lark-task"},
@@ -61,6 +62,15 @@ func TestReadState_Valid(t *testing.T) {
}
}
func TestEffectiveLayoutDefaultsLegacyStateToSeparate(t *testing.T) {
if got := EffectiveLayout(&SkillsState{Version: "1.0.0"}); got != LayoutSeparate {
t.Fatalf("EffectiveLayout(legacy) = %q, want separate", got)
}
if got := EffectiveLayout(&SkillsState{Layout: LayoutSuite}); got != LayoutSuite {
t.Fatalf("EffectiveLayout(suite) = %q, want suite", got)
}
}
func TestReadState_CorruptStateUnreadable(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
+295 -224
View File
@@ -6,19 +6,24 @@ package skillscheck
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/vfs"
)
var (
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
digestPattern = regexp.MustCompile(`^sha256:[0-9a-fA-F]{64}$`)
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
)
const githubSkillsSource = "larksuite/cli"
type SyncInput struct {
Version string
OfficialSkills []string
@@ -29,11 +34,12 @@ type SyncInput struct {
}
type SyncPlan struct {
Version string
OfficialSkills []string
ToUpdate []string
Added []string
SkippedDeleted []string
Version string
OfficialSkills []string
CleanupOfficial []string
ToUpdate []string
Added []string
SkippedDeleted []string
}
func stripANSI(s string) string {
@@ -42,47 +48,55 @@ func stripANSI(s string) string {
func ParseSkillsList(text string) []string {
text = stripANSI(text)
lines := strings.Split(text, "\n")
// Detect format type
hasGlobalSkills := strings.Contains(text, "Global Skills")
hasAvailableSkills := strings.Contains(text, "Available Skills")
if hasGlobalSkills {
// Format 1: locally installed skills list from "npx -y skills ls -g"
return parseGlobalSkillsList(lines)
} else if hasAvailableSkills {
// Format 2: official skills list from "npx -y skills add ... --list"
return parseOfficialSkillsList(lines)
if strings.Contains(text, "Global Skills") {
return parseGlobalSkillsList(strings.Split(text, "\n"))
}
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type installedSkill struct {
Name string `json:"name"`
Path string `json:"path"`
}
func parseInstalledSkillsJSON(text string) ([]installedSkill, error) {
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
return nil, err
}
seen := map[string]bool{}
seen := map[string]installedSkill{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
seen[candidate] = installedSkill{Name: candidate, Path: strings.TrimSpace(skill.Path)}
}
return sortedKeys(seen)
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
entries := make([]installedSkill, 0, len(names))
for _, name := range names {
entries = append(entries, seen[name])
}
return entries, nil
}
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
type officialSkill struct {
Name string `json:"name"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Digest string `json:"digest"`
}
type officialIndex struct {
Skills []officialSkill `json:"skills"`
@@ -96,9 +110,16 @@ func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
seen := map[string]bool{}
for _, skill := range index.Skills {
candidate := strings.TrimSpace(skill.Name)
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
if !skillNamePattern.MatchString(candidate) {
return nil, fmt.Errorf("invalid skill name %q", candidate)
}
if skill.Type != "archive" || strings.TrimSpace(skill.URL) == "" || !digestPattern.MatchString(skill.Digest) {
return nil, fmt.Errorf("skill %s is not a complete v0.2 archive entry", candidate)
}
if seen[candidate] {
return nil, fmt.Errorf("duplicate skill %s", candidate)
}
seen[candidate] = true
}
return sortedKeys(seen), nil
@@ -159,60 +180,27 @@ func isGlobalSkillsSectionHeader(line string) bool {
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
inAvailableSection := false
for _, line := range lines {
// Check if we've reached the "Available Skills" section
if strings.Contains(line, "Available Skills") {
inAvailableSection = true
continue
}
if !inAvailableSection {
continue
}
// Process lines containing "│", e.g. " │ lark-approval "
if strings.Contains(line, "│") {
// Remove all "│" characters and spaces, extract the first valid token in order
parts := strings.FieldsFunc(line, func(r rune) bool {
return r == '│' || r == ' '
})
if len(parts) > 0 {
candidate := parts[0]
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
}
}
return sortedKeys(seen)
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
previousOfficial := []string{}
if input.StateReadable && input.PreviousState != nil {
previousOfficial = input.PreviousState.OfficialSkills
}
cleanupOfficial := uniqueSorted(append(append([]string{}, official...), previousOfficial...))
if input.Force {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: official,
Added: []string{},
SkippedDeleted: []string{},
Version: input.Version,
OfficialSkills: official,
CleanupOfficial: cleanupOfficial,
ToUpdate: official,
Added: []string{},
SkippedDeleted: []string{},
}
}
officialSet := toSet(official)
installedOfficial := intersection(input.LocalSkills, officialSet)
previousOfficial := []string{}
if input.StateReadable && input.PreviousState != nil {
previousOfficial = input.PreviousState.OfficialSkills
}
previousSet := toSet(previousOfficial)
newAddedOfficial := []string{}
@@ -223,6 +211,9 @@ func PlanSync(input SyncInput) SyncPlan {
}
updateSet := toSet(installedOfficial)
if len(installedOfficial) == 0 {
updateSet = toSet(official)
}
for _, skill := range newAddedOfficial {
updateSet[skill] = true
}
@@ -237,40 +228,48 @@ func PlanSync(input SyncInput) SyncPlan {
}
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: toUpdate,
Added: uniqueSorted(newAddedOfficial),
SkippedDeleted: skipped,
Version: input.Version,
OfficialSkills: official,
CleanupOfficial: cleanupOfficial,
ToUpdate: toUpdate,
Added: uniqueSorted(newAddedOfficial),
SkippedDeleted: skipped,
}
}
type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult
SkillsSources() []string
FetchSkillsIndex(source string) *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
InstallSkills(source string, nameList []string) *selfupdate.NpmResult
InstallAllSkills(source string) *selfupdate.NpmResult
StageSuite(source, dir string) *selfupdate.NpmResult
InstallLocalSuite(path string) *selfupdate.NpmResult
RemoveGlobalSkills(names []string) *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Layout Layout
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
Action string
Official []string
Updated []string
Added []string
SkippedDeleted []string
Failed []string
Err error
Detail string
Force bool
Action string
Official []string
OfficialUnknown bool
Updated []string
Added []string
SkippedDeleted []string
Failed []string
Err error
Detail string
Warning string
Layout Layout
Force bool
}
func SyncSkills(opts SyncOptions) *SyncResult {
@@ -281,176 +280,248 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
// --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
// --- Step 3: Read previous state ---
previous, readable, err := ReadState()
if err != nil {
readable = false
previous = nil
}
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
result := &SyncResult{
Action: "synced",
Official: plan.OfficialSkills,
Updated: plan.ToUpdate,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
targetLayout, err := ResolveLayout(opts.Layout, previous, readable)
if err != nil {
return &SyncResult{Action: "failed", Err: err}
}
installed, err := listInstalledSkills(opts.Runner)
if err != nil {
return &SyncResult{Action: "failed", Layout: targetLayout, Err: err}
}
localOfficial, err := localOfficialSkills(installed, previous, readable)
if err != nil {
// A suite whose installed path or references cannot be read is treated as
// absent. Planning from the old state would otherwise produce no updates
// and leave the damaged installation in place.
localOfficial = nil
readable = false
previous = nil
} else if readable && previous != nil && previous.OfficialSkillsUnknown {
// A cold GitHub fallback installed content without a trustworthy official
// list. Retry the official sources as a cold sync, even at the same version.
readable = false
previous = nil
}
if len(plan.ToUpdate) == 0 {
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(installResult), official)
}
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
return result
}
return result
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
var fallbackPlan *SyncPlan
for _, source := range opts.Runner.SkillsSources() {
official, fetchErr := fetchOfficialSkills(opts.Runner, source)
if fetchErr != nil {
reasons = append(reasons, source+": "+fetchErr.Error())
continue
}
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
LocalSkills: localOfficial,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
fallbackPlan = &plan
indexResult := runner.ListOfficialSkillsIndex()
if indexResult == nil || indexResult.Err != nil {
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
} else {
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
if err != nil {
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
} else if len(official) > 0 {
return official, "", true
} else {
reasons = append(reasons, "official skills index contains no skills")
if syncErr := syncLayout(opts.Runner, source, targetLayout, plan, installed); syncErr != nil {
reasons = append(reasons, source+": "+syncErr.Error())
continue
}
return finishSync(opts, targetLayout, plan, "", "", false)
}
if targetLayout == LayoutSuite {
return &SyncResult{
Action: "failed",
Layout: targetLayout,
Err: fmt.Errorf("suite skills sync failed: %s", strings.Join(reasons, "; ")),
Detail: strings.Join(reasons, "\n"),
Force: opts.Force,
}
}
officialResult := runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
return nil, strings.Join(reasons, "; "), false
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) > 0 {
return official, "", true
}
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
} else {
reasons = append(reasons, "official skills list returned no skills")
}
return nil, strings.Join(reasons, "; "), false
return fallbackSeparate(opts, previous, readable, localOfficial, installed, fallbackPlan, reasons)
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
func fetchOfficialSkills(runner SkillsRunner, source string) ([]string, error) {
result := runner.FetchSkillsIndex(source)
if result == nil || result.Err != nil {
return nil, fmt.Errorf("index request failed: %s", resultDetail(result))
}
official, err := ParseOfficialSkillsIndexJSON(result.Stdout.String())
if err != nil {
return nil, fmt.Errorf("invalid v0.2 index: %w", err)
}
if len(official) == 0 {
return nil, fmt.Errorf("v0.2 index contains no skills")
}
return official, nil
}
func listInstalledSkills(runner SkillsRunner) ([]installedSkill, error) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
if installed, err := parseInstalledSkillsJSON(jsonResult.Stdout.String()); err == nil {
return installed, nil
}
}
textResult := runner.ListGlobalSkills()
if textResult != nil && textResult.Err == nil {
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
return local, true
names := ParseSkillsList(textResult.Stdout.String())
if names != nil {
installed := make([]installedSkill, 0, len(names))
for _, name := range names {
installed = append(installed, installedSkill{Name: name})
}
return installed, nil
}
}
return nil, false
return nil, fmt.Errorf("local skills list failed")
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state
// records the full official list; otherwise a minimal state (version only) is
// written to break the fallback loop.
func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult {
installResult := opts.Runner.InstallAllSkills()
if installResult == nil {
return &SyncResult{
Action: "fallback_failed",
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
Detail: reason,
Force: opts.Force,
}
}
if installResult.Err != nil {
return &SyncResult{
Action: "fallback_failed",
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
Detail: reason + "\n" + resultDetail(installResult),
Force: opts.Force,
func localOfficialSkills(installed []installedSkill, previous *SkillsState, readable bool) ([]string, error) {
if !readable || previous == nil || EffectiveLayout(previous) == LayoutSeparate {
names := make([]string, 0, len(installed))
for _, skill := range installed {
names = append(names, skill.Name)
}
return names, nil
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: official,
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
for _, skill := range installed {
if skill.Name != "lark-suite" {
continue
}
if skill.Path == "" {
return nil, fmt.Errorf("cannot inspect installed lark-suite: global skills JSON did not include its path")
}
return listDirectSubdirs(filepath.Join(skill.Path, "references"))
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{
Action: "fallback_synced",
Official: official,
Updated: official,
Added: official,
SkippedDeleted: []string{},
Detail: reason + "\nstate write failed: " + writeErr.Error(),
return nil, fmt.Errorf("cannot inspect installed lark-suite: skill is not installed")
}
func listDirectSubdirs(root string) ([]string, error) {
entries, err := vfs.ReadDir(root)
if err != nil {
return nil, err
}
names := []string{}
for _, entry := range entries {
if entry.IsDir() {
names = append(names, entry.Name())
}
}
sort.Strings(names)
return names, nil
}
func syncLayout(runner SkillsRunner, source string, layout Layout, plan SyncPlan, installed []installedSkill) error {
if layout == LayoutSuite {
return syncSuite(runner, source, plan, installed)
}
if len(plan.ToUpdate) > 0 {
result := runner.InstallSkills(source, plan.ToUpdate)
if result == nil || result.Err != nil {
return fmt.Errorf("archive install failed: %s", resultDetail(result))
}
}
if hasInstalledSkill(installed, "lark-suite") {
if result := runner.RemoveGlobalSkills([]string{"lark-suite"}); result == nil || result.Err != nil {
return fmt.Errorf("remove lark-suite failed: %s", resultDetail(result))
}
}
return nil
}
func fallbackSeparate(opts SyncOptions, previous *SkillsState, readable bool, local []string, installed []installedSkill, plan *SyncPlan, reasons []string) *SyncResult {
if plan == nil && readable && previous != nil && len(previous.OfficialSkills) > 0 {
fallback := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: previous.OfficialSkills,
LocalSkills: local,
PreviousState: previous,
StateReadable: true,
Force: opts.Force,
}
})
plan = &fallback
}
return &SyncResult{
Action: "fallback_synced",
Official: official,
Updated: official,
Added: official,
SkippedDeleted: []string{},
Detail: reason,
Force: opts.Force,
var installResult *selfupdate.NpmResult
officialUnknown := plan == nil
if plan == nil {
installResult = opts.Runner.InstallAllSkills(githubSkillsSource)
} else if len(plan.ToUpdate) > 0 {
installResult = opts.Runner.InstallSkills(githubSkillsSource, plan.ToUpdate)
}
if installResult != nil && installResult.Err != nil {
reasons = append(reasons, githubSkillsSource+": "+resultDetail(installResult))
return &SyncResult{
Action: "failed",
Layout: LayoutSeparate,
Err: fmt.Errorf("separate skills sync failed: %s", strings.Join(reasons, "; ")),
Detail: strings.Join(reasons, "\n"),
Force: opts.Force,
}
}
if hasInstalledSkill(installed, "lark-suite") {
if result := opts.Runner.RemoveGlobalSkills([]string{"lark-suite"}); result == nil || result.Err != nil {
return &SyncResult{Action: "failed", Layout: LayoutSeparate, Err: fmt.Errorf("remove lark-suite failed: %s", resultDetail(result)), Force: opts.Force}
}
}
if plan == nil {
empty := SyncPlan{Version: opts.Version}
plan = &empty
}
warning := ""
if installResult != nil {
warning = "used the GitHub legacy fallback; installed Skill content may be incomplete because the legacy protocol can ignore individual file download failures"
}
return finishSync(opts, LayoutSeparate, *plan, "fallback_synced", warning, officialUnknown)
}
func finishSync(opts SyncOptions, layout Layout, plan SyncPlan, action, warning string, officialUnknown bool) *SyncResult {
if action == "" {
action = "synced"
}
result := &SyncResult{
Action: action,
Official: plan.OfficialSkills,
OfficialUnknown: officialUnknown,
Updated: plan.ToUpdate,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Warning: warning,
Layout: layout,
Force: opts.Force,
}
state := SkillsState{
Version: opts.Version,
Layout: layout,
OfficialSkills: plan.OfficialSkills,
OfficialSkillsUnknown: officialUnknown,
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
}
return result
}
func hasInstalledSkill(installed []installedSkill, name string) bool {
for _, skill := range installed {
if skill.Name == name {
return true
}
}
return false
}
func resultDetail(result *selfupdate.NpmResult) string {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func TestSuiteTemplateMatchesCropContract(t *testing.T) {
repoRoot := filepath.Join("..", "..")
raw, err := os.ReadFile(filepath.Join(repoRoot, "isolated-skills", "lark-suite", "SKILL.md"))
if err != nil {
t.Fatal(err)
}
template := string(raw)
if !strings.Contains(template, suiteDescriptionPrefix+"<!-- LARK_SUITE_KEYS -->"+suiteDescriptionSuffix) {
t.Fatal("suite template description no longer matches the client crop contract")
}
if strings.Count(template, "<!-- LARK_SUITE_ROUTES -->") != 1 {
t.Fatal("suite template must contain exactly one route placeholder")
}
}
func TestSuiteKeywordKeysMatchOfficialSkillDirectories(t *testing.T) {
repoRoot := filepath.Join("..", "..")
raw, err := os.ReadFile(filepath.Join(repoRoot, "skill-template", "lark-suite-business-info.json"))
if err != nil {
t.Fatal(err)
}
var keywords map[string][]string
if err := json.Unmarshal(raw, &keywords); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(filepath.Join(repoRoot, "skills"))
if err != nil {
t.Fatal(err)
}
directories := []string{}
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), "lark-") {
directories = append(directories, entry.Name())
}
}
sort.Strings(directories)
keys := make([]string, 0, len(keywords))
for name := range keywords {
keys = append(keys, name)
}
sort.Strings(keys)
assertStrings(t, keys, directories)
}
+34
View File
@@ -0,0 +1,34 @@
---
name: lark-suite
version: 0.1.0
description: 飞书/Lark 聚合能力入口:管理飞书/Lark 产品能力(<!-- LARK_SUITE_KEYS -->等)。当 doubao.com 及其子域名承载飞书资源时也使用本入口,不要回退到 WebFetch。当用户需求涉及上述飞书业务域时使用。
metadata:
requires:
bins:
- lark-cli
---
# Lark Suite
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
所有子能力统一收纳在当前 skill 的 `references/` 目录。选择 `lark-foo` 后,直接读取 `references/lark-foo/SKILL.md`;不要再次调用 `Skill(lark-foo)`,也不要使用 Find/Glob 遍历或探测整个 references 目录。
## 使用流程
1. 根据用户意图从下方路由表选择一个或多个子能力即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
2. 直接读取 `references/<skill-name>/SKILL.md` 加载所选子能力,不要把收纳后的子能力当作独立 skill 再次调用。
3. 仅使用本文件列出的路由与对应子能力入口,不要遍历或探测其他技能目录。
4. 如果目标能力未列出,返回无法路由的明确提示。
5. 仅读取当前已选子能力明确要求的前置文件。
6. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
## 能力路由
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
<!-- LARK_SUITE_ROUTES -->
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
func TestInstallWizardUsesRegularSkillsRoute(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test file path")
}
contents, err := vfs.ReadFile(filepath.Join(filepath.Dir(currentFile), "scripts", "install-wizard.js"))
if err != nil {
t.Fatalf("read install wizard: %v", err)
}
const route = `const SKILLS_REPO = "https://open.feishu.cn/lark-cli/skills/regular";`
if !strings.Contains(string(contents), route) {
t.Fatalf("install wizard must use the production regular Agent Skills route")
}
}
func TestLarkSuiteArchivePathsFitSkillsInstallerTarLimit(t *testing.T) {
// npx skills v1.5.21 did not honor PAX long-path metadata while extracting
// Agent Skills v0.2 archives and instead used tar's 100-byte name field. A
// 101-byte suite entry ending in .md was consequently installed as .m. Keep
// generated suite paths within this compatibility limit until the upstream
// extractor reliably supports long archive paths.
const maxTarPathBytes = 100
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test file path")
}
skillsRoot := filepath.Join(filepath.Dir(currentFile), "skills")
var walk func(string, string)
walk = func(dir, relativeDir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatalf("read skills directory %s: %v", dir, err)
}
for _, entry := range entries {
relativePath := filepath.Join(relativeDir, entry.Name())
archivePath := filepath.ToSlash(filepath.Join("references", relativePath))
if entry.IsDir() {
archivePath += "/"
}
if pathBytes := len([]byte(archivePath)); pathBytes > maxTarPathBytes {
t.Errorf("suite archive path %q is %d bytes; npx skills compatibility limit is %d bytes", archivePath, pathBytes, maxTarPathBytes)
}
if entry.IsDir() {
walk(filepath.Join(dir, entry.Name()), relativePath)
}
}
}
walk(skillsRoot, "")
}
+1 -1
View File
@@ -13,7 +13,7 @@ const { execFileSync, execFile } = require("child_process");
let p;
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO = "https://open.feishu.cn/lark-cli/skills/regular";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const isWindows = process.platform === "win32";
@@ -0,0 +1,29 @@
{
"lark-approval": ["审批"],
"lark-apps": ["妙搭应用", "应用托管", "静态站点", "网页部署", "app_ 应用", "应用数据库", "线上日志", "部署上线", "本地开发"],
"lark-attendance": ["考勤", "迟到", "早退", "打卡查询"],
"lark-base": ["多维表格", "Base", "Bitable", "数据表", "仪表盘", "/base/"],
"lark-calendar": ["日历", "会议室", "管理参会人"],
"lark-contact": ["通讯录", "联系人", "联系方式", "电话", "邮箱", "部门", "组织信息"],
"lark-doc": ["云文档", "文档", "/docx/"],
"lark-drive": ["云盘", "云空间", "文件夹", "文件管理", "/drive/", "/file/"],
"lark-event": ["事件订阅", "实时事件", "监听"],
"lark-im": ["即时通讯", "消息", "群聊"],
"lark-mail": ["邮箱", "邮件"],
"lark-markdown": ["Markdown 文档", ".md", "MD 文件"],
"lark-minutes": ["妙记", "音视频转写", "/minutes/"],
"lark-note": ["会议纪要"],
"lark-okr": ["OKR"],
"lark-openapi-explorer": ["开放平台文档", "OpenAPI"],
"lark-shared": ["授权", "配置", "登录", "登录态", "身份", "版本", "更新"],
"lark-sheets": ["电子表格", "在线表格", "工作表", "Excel", "xlsx", "/sheets/"],
"lark-skill-maker": ["CLI Skill", "自定义 Skill", "能力封装"],
"lark-slides": ["演示文稿", "幻灯片", "PPT", "/slides/"],
"lark-task": ["任务", "待办", "任务智能体", "父任务", "子任务", "负责人", "截止时间", "任务清单", "任务搜索", "多步任务", "任务提醒"],
"lark-vc": ["视频会议", "会议纪要"],
"lark-vc-agent": ["会中能力", "视频会议机器人"],
"lark-whiteboard": ["画板", "图表"],
"lark-wiki": ["知识库", "知识空间", "空间目录", "/wiki/"],
"lark-workflow-meeting-summary": ["会议纪要工作流", "会议纪要", "会议周报"],
"lark-workflow-standup-report": ["站会日报工作流"]
}