mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
feat: support separate and suite skill layouts (#2211)
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+459
-571
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)
|
||||
}
|
||||
@@ -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 -->
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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": ["站会日报工作流"]
|
||||
}
|
||||
Reference in New Issue
Block a user