Skip to content

Commit e0867e6

Browse files
feat: support separate and suite skill layouts (#2211)
1 parent 679ebd5 commit e0867e6

20 files changed

Lines changed: 1588 additions & 950 deletions

cmd/notice_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/larksuite/cli/internal/deprecation"
11+
"github.com/larksuite/cli/internal/skillscheck"
1112
)
1213

1314
// composePendingNotice must surface a deprecated-command alias under the
@@ -45,6 +46,27 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
4546
}
4647
}
4748

49+
func TestComposePendingNoticeOfficialSkillsUnknown(t *testing.T) {
50+
t.Cleanup(func() { skillscheck.SetPending(nil) })
51+
skillscheck.SetPending(&skillscheck.StaleNotice{
52+
Current: "1.0.21",
53+
Target: "1.0.21",
54+
OfficialUnknown: true,
55+
})
56+
57+
got := composePendingNotice(nil)
58+
entry, ok := got["skills"].(map[string]interface{})
59+
if !ok {
60+
t.Fatalf("missing skills notice: %#v", got)
61+
}
62+
if entry["official_unknown"] != true {
63+
t.Fatalf("skills notice = %#v, want official_unknown=true", entry)
64+
}
65+
if entry["command"] != "lark-cli update" {
66+
t.Fatalf("skills notice command = %v, want lark-cli update", entry["command"])
67+
}
68+
}
69+
4870
// With nothing pending, the provider returns nil so no "_notice" field is
4971
// emitted on a clean run.
5072
func TestComposePendingNoticeEmpty(t *testing.T) {

cmd/root.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,16 @@ func composePendingNotice(plan *surface.Plan) map[string]interface{} {
194194
}
195195
}
196196
if stale := skillscheck.GetPending(); stale != nil {
197-
notice["skills"] = map[string]interface{}{
197+
entry := map[string]interface{}{
198198
"current": stale.Current,
199199
"target": stale.Target,
200200
"message": stale.Message(),
201201
"command": "lark-cli update",
202202
}
203+
if stale.OfficialUnknown {
204+
entry["official_unknown"] = true
205+
}
206+
notice["skills"] = entry
203207
}
204208
}
205209
if dep := deprecation.GetPending(); dep != nil {

cmd/update/update.go

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,11 @@ func symArrow() string {
8888

8989
// UpdateOptions holds inputs for the update command.
9090
type UpdateOptions struct {
91-
Factory *cmdutil.Factory
92-
JSON bool
93-
Force bool
94-
Check bool
91+
Factory *cmdutil.Factory
92+
JSON bool
93+
Force bool
94+
Check bool
95+
SkillsLayout string
9596
}
9697

9798
// NewCmdUpdate creates the update command.
@@ -109,7 +110,9 @@ Detects the installation method automatically:
109110
- manual/other: shows GitHub Releases download URL
110111
111112
Use --json for structured output (for AI agents and scripts).
112-
Use --check to only check for updates without installing.`,
113+
Use --check to only check for updates without installing.
114+
115+
The skill name "lark-suite" is reserved for CLI-managed suite layout.`,
113116
RunE: func(cmd *cobra.Command, args []string) error {
114117
return updateRun(opts)
115118
},
@@ -118,13 +121,24 @@ Use --check to only check for updates without installing.`,
118121
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
119122
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
120123
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
124+
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or suite")
121125
cmdutil.SetRisk(cmd, "high-risk-write")
122126

123127
return cmd
124128
}
125129

126130
func updateRun(opts *UpdateOptions) error {
127131
io := opts.Factory.IOStreams
132+
if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil {
133+
return reportError(opts, io, "validation",
134+
errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or suite").WithParam("--skills-layout"))
135+
}
136+
if opts.Check && strings.TrimSpace(opts.SkillsLayout) != "" {
137+
return reportError(opts, io, "validation",
138+
errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout cannot be used with --check").
139+
WithParam("--skills-layout").
140+
WithHint("Remove --skills-layout when using --check."))
141+
}
128142
cur := currentVersion()
129143
updater := newUpdater()
130144
// Brand only steers skills sync. updateRun skips that resolution in --check,
@@ -152,7 +166,10 @@ func updateRun(opts *UpdateOptions) error {
152166
if !opts.Force && !update.IsNewer(latest, cur) {
153167
var skillsResult *skillscheck.SyncResult
154168
if !opts.Check {
155-
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
169+
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout)
170+
if err := reportSkillsFailure(opts, io, skillsResult); err != nil {
171+
return err
172+
}
156173
}
157174
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
158175
}
@@ -195,10 +212,18 @@ func resolveSkillsBrand(f *cmdutil.Factory, errOut stdio.Writer) core.LarkBrand
195212
// error's exit code bare; human mode returns the typed error for the
196213
// dispatcher to render.
197214
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
215+
return reportErrorWithFields(opts, io, errType, typedErr, nil)
216+
}
217+
218+
func reportErrorWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError, fields map[string]interface{}) error {
198219
if opts.JSON {
199-
output.PrintJson(io.Out, map[string]interface{}{
200-
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
201-
})
220+
out := make(map[string]interface{}, len(fields)+2)
221+
for key, value := range fields {
222+
out[key] = value
223+
}
224+
out["ok"] = false
225+
out["error"] = map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message}
226+
output.PrintJson(io.Out, out)
202227
return output.ErrBare(output.ExitCodeOf(typedErr))
203228
}
204229
return typedErr
@@ -229,8 +254,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
229254
}
230255

231256
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
232-
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
233-
257+
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout)
234258
reason := detect.ManualReason()
235259
if opts.JSON {
236260
out := map[string]interface{}{
@@ -240,6 +264,9 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
240264
"url": releaseURL(latest), "changelog": changelogURL(),
241265
}
242266
applySkillsResult(out, skillsResult)
267+
if err := reportSkillsFailureWithFields(opts, io, skillsResult, out); err != nil {
268+
return err
269+
}
243270
output.PrintJson(io.Out, out)
244271
return nil
245272
}
@@ -252,6 +279,9 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
252279
} else {
253280
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)
254281
}
282+
if err := reportSkillsFailure(opts, io, skillsResult); err != nil {
283+
return err
284+
}
255285
emitSkillsTextHints(io, skillsResult)
256286
return nil
257287
}
@@ -319,7 +349,21 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
319349
return output.ErrBare(output.ExitAPI)
320350
}
321351

322-
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
352+
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout)
353+
if skillsResult != nil && skillsResult.Err != nil {
354+
fields := map[string]interface{}{
355+
"previous_version": cur, "current_version": latest,
356+
"latest_version": latest, "action": "updated",
357+
"message": fmt.Sprintf("lark-cli updated from %s to %s, but skills update failed", cur, latest),
358+
"url": releaseURL(latest), "changelog": changelogURL(),
359+
}
360+
applySkillsResult(fields, skillsResult)
361+
if !opts.JSON {
362+
fmt.Fprintf(io.ErrOut, "\n%s lark-cli binary updated from %s to %s\n", symOK(), cur, latest)
363+
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
364+
}
365+
return reportSkillsFailureWithFields(opts, io, skillsResult, fields)
366+
}
323367

324368
if opts.JSON {
325369
result := map[string]interface{}{
@@ -366,14 +410,18 @@ func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) str
366410
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))
367411
}
368412

369-
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
413+
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult {
414+
layout, _ := skillscheck.ParseLayout(requestedLayout)
370415
if !force {
371-
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
372-
return nil
416+
if state, ok, err := skillscheck.ReadState(); err == nil && ok && normalizeVersion(state.Version) == normalizeVersion(stateVersion) {
417+
if !state.OfficialSkillsUnknown && (layout == "" || skillscheck.EffectiveLayout(state) == layout) {
418+
return nil
419+
}
373420
}
374421
}
375422
result := syncSkills(skillscheck.SyncOptions{
376423
Version: stateVersion,
424+
Layout: layout,
377425
Force: force,
378426
Runner: updater,
379427
})
@@ -383,6 +431,20 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
383431
return result
384432
}
385433

434+
func reportSkillsFailure(opts *UpdateOptions, io *cmdutil.IOStreams, result *skillscheck.SyncResult) error {
435+
return reportSkillsFailureWithFields(opts, io, result, nil)
436+
}
437+
438+
func reportSkillsFailureWithFields(opts *UpdateOptions, io *cmdutil.IOStreams, result *skillscheck.SyncResult, fields map[string]interface{}) error {
439+
if result == nil || result.Err == nil {
440+
return nil
441+
}
442+
typedErr := errs.NewInternalError(errs.SubtypeUnknown, "skills update failed: %s", result.Err).
443+
WithHint("retry with `lark-cli update --force`").
444+
WithCause(result.Err)
445+
return reportErrorWithFields(opts, io, "skills_update_error", typedErr, fields)
446+
}
447+
386448
// reportAlreadyUpToDate emits the JSON / pretty output for the
387449
// already-up-to-date branch, including any skills_action / skills_warning
388450
// fields derived from skillsResult. When check is true, this is the pure
@@ -418,9 +480,11 @@ func applySkillsStatus(env map[string]interface{}, target string) {
418480
status := map[string]interface{}{
419481
"current": state.Version,
420482
"target": target,
421-
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
483+
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target) && !state.OfficialSkillsUnknown,
422484
}
423-
if len(state.OfficialSkills) > 0 {
485+
if state.OfficialSkillsUnknown {
486+
status["official_unknown"] = true
487+
} else if len(state.OfficialSkills) > 0 {
424488
status["official"] = len(state.OfficialSkills)
425489
}
426490
if len(state.UpdatedSkills) > 0 {
@@ -429,6 +493,7 @@ func applySkillsStatus(env map[string]interface{}, target string) {
429493
if len(state.SkippedDeletedSkills) > 0 {
430494
status["skipped_deleted"] = state.SkippedDeletedSkills
431495
}
496+
status["layout"] = skillscheck.EffectiveLayout(state)
432497
env["skills_status"] = status
433498
}
434499

@@ -443,15 +508,23 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
443508
default:
444509
env["skills_action"] = "synced"
445510
env["skills_summary"] = skillsSummary(r)
511+
if r.Warning != "" {
512+
env["skills_warning"] = r.Warning
513+
}
446514
}
447515
}
448516

449517
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
450518
summary := map[string]interface{}{
451-
"official": len(r.Official),
452519
"updated": len(r.Updated),
453520
"added": len(r.Added),
454521
"skipped_deleted": len(r.SkippedDeleted),
522+
"layout": r.Layout,
523+
}
524+
if r.OfficialUnknown {
525+
summary["official_unknown"] = true
526+
} else {
527+
summary["official"] = len(r.Official)
455528
}
456529
if len(r.Failed) > 0 {
457530
summary["failed"] = r.Failed
@@ -468,10 +541,13 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
468541
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
469542
}
470543
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
544+
case r.Warning != "":
545+
fmt.Fprintf(io.ErrOut, "%s Skills updated using %s layout\n", symOK(), r.Layout)
546+
fmt.Fprintf(io.ErrOut, "%s %s\n", symWarn(), r.Warning)
471547
case r.Force:
472-
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
548+
fmt.Fprintf(io.ErrOut, "%s Skills updated using %s layout: restored all %d official skills\n", symOK(), r.Layout, len(r.Official))
473549
default:
474-
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))
550+
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))
475551
if len(r.SkippedDeleted) > 0 {
476552
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
477553
}

0 commit comments

Comments
 (0)