diff --git a/Makefile b/Makefile index 671c0e69e2..450bd59bea 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ script-test: bash scripts/ci-workflow.test.sh bash scripts/release-workflow.test.sh bash scripts/semantic-review-workflow.test.sh - $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/release-publish-policy.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js + $(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/install-wizard.test.js scripts/release-preflight.test.js scripts/release-publish-policy.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js # ./extension/... keeps the public plugin SDK in the default test matrix. unit-test: fetch_meta diff --git a/README.md b/README.md index db720a6379..9dc493feb6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ Choose **one** of the following methods: npx @larksuite/cli@latest install ``` +The wizard also installs the AI Agent [Skills](#agent-skills). Pass `--no-skills` to install only the CLI; skills can be added later with `npx skills add larksuite/cli -y -g`, and `lark-cli update` leaves skills alone until they are installed. + **Option 2 — From source:** Requires Go `v1.23`+ and Python 3. diff --git a/README.zh.md b/README.zh.md index 95722b39d6..d083178e01 100644 --- a/README.zh.md +++ b/README.zh.md @@ -75,6 +75,8 @@ npx @larksuite/cli@latest install ``` +安装向导会同时安装 AI Agent [Skills](#agent-skills)。加 `--no-skills` 可以只安装 CLI;之后随时可用 `npx skills add larksuite/cli -y -g` 补装,未安装 Skills 时 `lark-cli update` 不会自动安装。 + **方式二 — 从源码安装:** 需要 Go `v1.23`+ 和 Python 3。 diff --git a/cmd/update/update.go b/cmd/update/update.go index 8f5d7c44ed..5dedbf0446 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" + "github.com/larksuite/cli/internal/skillcontent" "github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/update" ) @@ -37,6 +38,9 @@ var ( syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) } ) +// skillsInstallCommand matches the README install step so hints stay in sync. +const skillsInstallCommand = "npx skills add larksuite/cli -y -g" + func isWindows() bool { return currentOS == osWindows } // normalizeVersion canonicalizes a version string for state comparison. @@ -112,6 +116,11 @@ Detects the installation method automatically: Use --json for structured output (for AI agents and scripts). Use --check to only check for updates without installing. +Official skills are synced only when they are already installed. When no +official skill is installed and no sync state exists, skills are left alone; +install them with: npx skills add larksuite/cli -y -g +(--force and --skills-layout also install them). + The skill name "lark-suite" is reserved for CLI-managed suite layout.`, RunE: func(cmd *cobra.Command, args []string) error { return updateRun(opts) @@ -166,7 +175,7 @@ func updateRun(opts *UpdateOptions) error { if !opts.Force && !update.IsNewer(latest, cur) { var skillsResult *skillscheck.SyncResult if !opts.Check { - skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout) + skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, embeddedOfficialSkills(opts.Factory)) if err := reportSkillsFailure(opts, io, skillsResult); err != nil { return err } @@ -254,7 +263,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, opts.SkillsLayout) + skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, embeddedOfficialSkills(opts.Factory)) reason := detect.ManualReason() if opts.JSON { out := map[string]interface{}{ @@ -349,7 +358,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string return output.ErrBare(output.ExitAPI) } - skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout) + skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, embeddedOfficialSkills(opts.Factory)) if skillsResult != nil && skillsResult.Err != nil { fields := map[string]interface{}{ "previous_version": cur, "current_version": latest, @@ -379,7 +388,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) - if skillsResult != nil { + if skillsResult != nil && skillsResult.Action != skillscheck.ActionNotInstalled { skillsPM := "npx" if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable { skillsPM = "pnpm dlx" @@ -410,7 +419,7 @@ 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, requestedLayout string) *skillscheck.SyncResult { +func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string, officialNames []string) *skillscheck.SyncResult { layout, _ := skillscheck.ParseLayout(requestedLayout) if !force { if state, ok, err := skillscheck.ReadState(); err == nil && ok && normalizeVersion(state.Version) == normalizeVersion(stateVersion) { @@ -420,10 +429,11 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state } } result := syncSkills(skillscheck.SyncOptions{ - Version: stateVersion, - Layout: layout, - Force: force, - Runner: updater, + Version: stateVersion, + Layout: layout, + Force: force, + Runner: updater, + KnownOfficialSkills: officialNames, }) if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") { fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err) @@ -431,6 +441,23 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state return result } +// embeddedOfficialSkills lists the official skill names embedded in this +// binary, or nil when the build ships none. +func embeddedOfficialSkills(f *cmdutil.Factory) []string { + if f == nil || f.SkillContent == nil { + return nil + } + infos, err := skillcontent.New(f.SkillContent).List() + if err != nil { + return nil + } + names := make([]string, 0, len(infos)) + for _, info := range infos { + names = append(names, info.Name) + } + return names +} + func reportSkillsFailure(opts *UpdateOptions, io *cmdutil.IOStreams, result *skillscheck.SyncResult) error { return reportSkillsFailureWithFields(opts, io, result, nil) } @@ -505,6 +532,9 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) { env["skills_action"] = "failed" env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err) env["skills_summary"] = skillsSummary(r) + case r.Action == skillscheck.ActionNotInstalled: + env["skills_action"] = skillscheck.ActionNotInstalled + env["skills_hint"] = "official skills are not installed; to install them run: " + skillsInstallCommand default: env["skills_action"] = "synced" env["skills_summary"] = skillsSummary(r) @@ -541,6 +571,9 @@ 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.Action == skillscheck.ActionNotInstalled: + fmt.Fprintf(io.ErrOut, "%s Skills not installed; skills sync skipped\n", symArrow()) + fmt.Fprintf(io.ErrOut, " To install official skills: %s\n", skillsInstallCommand) 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) diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 68df818984..0ce8f687f5 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" "time" "github.com/larksuite/cli/errs" @@ -1201,7 +1202,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, "", nil) if got != nil { t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got) } @@ -1226,7 +1227,7 @@ func TestRunSkillsAndState_RequestedLayoutBypassesVersionDedup(t *testing.T) { return &skillscheck.SyncResult{Action: "synced", Layout: skillscheck.LayoutSuite} } - got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "suite") + got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "suite", nil) if !called || got == nil || got.Err != nil { t.Fatalf("runSkillsAndState() = %+v, called = %v", got, called) } @@ -1249,7 +1250,7 @@ func TestRunSkillsAndState_UnknownOfficialSkillsBypassesVersionDedup(t *testing. return &skillscheck.SyncResult{Action: "synced", Layout: skillscheck.LayoutSeparate} } - got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "") + got := runSkillsAndState(&selfupdate.Updater{}, newTestIO(), "1.0.21", false, "", nil) if !called || got == nil || got.Err != nil { t.Fatalf("runSkillsAndState() = %+v, called = %v", got, called) } @@ -1316,7 +1317,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, "", nil) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got) } @@ -1331,7 +1332,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, "", nil) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got) } @@ -1357,7 +1358,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, "", nil) if got == nil || got.Err == nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got) } @@ -1650,7 +1651,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, "", nil) if got == nil || got.Err == nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got) } @@ -2044,3 +2045,163 @@ func TestResolveSkillsBrand_RespectsActiveProfile(t *testing.T) { t.Errorf("unexpected notice: %q", errBuf.String()) } } + +func TestUpdateRun_AlreadyLatest_NothingInstalled_SkipsSkillsSync(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + origFetch := fetchLatest + origCur := currentVersion + t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur }) + fetchLatest = func() (string, error) { return "1.0.21", nil } + currentVersion = func() string { return "1.0.21" } + + var skillsCommands []string + origNew := newUpdater + t.Cleanup(func() { newUpdater = origNew }) + newUpdater = func() *selfupdate.Updater { + return &selfupdate.Updater{ + SkillsIndexFetchOverride: func() *selfupdate.NpmResult { + t.Error("skills index fetched although no official skill is installed") + return &selfupdate.NpmResult{Err: fmt.Errorf("unexpected index fetch")} + }, + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { + joined := strings.Join(args, " ") + skillsCommands = append(skillsCommands, joined) + r := &selfupdate.NpmResult{} + if joined == "-y skills ls -g --json" { + r.Stdout.WriteString(`[{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`) + } + return r + }, + } + } + + f, stdout, _ := newTestFactory(t) + if err := updateRun(&UpdateOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("updateRun() err = %v, want nil", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) + } + if env["skills_action"] != skillscheck.ActionNotInstalled { + t.Errorf("skills_action = %v, want %q", env["skills_action"], skillscheck.ActionNotInstalled) + } + if hint, _ := env["skills_hint"].(string); !strings.Contains(hint, skillsInstallCommand) { + t.Errorf("skills_hint = %q, want install command %q", hint, skillsInstallCommand) + } + for _, command := range skillsCommands { + if strings.Contains(command, "skills add") { + t.Errorf("skills add was run for a CLI-only install: %q", command) + } + } + if _, readable, err := skillscheck.ReadState(); readable || err != nil { + t.Errorf("ReadState() = (_, %v, %v), want no state written", readable, err) + } +} + +func TestEmitSkillsTextHints_NotInstalled(t *testing.T) { + f, _, stderr := newTestFactory(t) + emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Action: skillscheck.ActionNotInstalled, Layout: skillscheck.LayoutSeparate}) + out := stderr.String() + if !strings.Contains(out, "Skills not installed") || !strings.Contains(out, skillsInstallCommand) { + t.Errorf("stderr = %q, want not-installed notice with install command", out) + } +} + +func TestRunSkillsAndState_CustomLarkPrefixedSkillIsNotInstalled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + updater := &selfupdate.Updater{ + SkillsIndexFetchOverride: func() *selfupdate.NpmResult { + t.Error("skills index fetched although only a custom lark- skill is installed") + return &selfupdate.NpmResult{Err: fmt.Errorf("unexpected index fetch")} + }, + SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + if strings.Join(args, " ") == "-y skills ls -g --json" { + r.Stdout.WriteString(`[{"name":"lark-custom","path":"/tmp/lark-custom","scope":"global","agents":["Codex"]}]`) + } + return r + }, + } + + got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", []string{"lark-calendar", "lark-mail"}) + if got == nil || got.Err != nil || got.Action != skillscheck.ActionNotInstalled { + t.Fatalf("runSkillsAndState() = %+v, want action %q", got, skillscheck.ActionNotInstalled) + } + if _, readable, err := skillscheck.ReadState(); readable || err != nil { + t.Errorf("ReadState() = (_, %v, %v), want no state written", readable, err) + } +} + +func TestEmbeddedOfficialSkills_ReadsFactorySkillContent(t *testing.T) { + if got := embeddedOfficialSkills(nil); got != nil { + t.Errorf("embeddedOfficialSkills(nil) = %v, want nil", got) + } + f, _, _ := newTestFactory(t) + f.SkillContent = nil + if got := embeddedOfficialSkills(f); got != nil { + t.Errorf("embeddedOfficialSkills(no content) = %v, want nil", got) + } + f.SkillContent = fstest.MapFS{ + "lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: calendar\n---\n")}, + "lark-mail/SKILL.md": {Data: []byte("---\nname: lark-mail\ndescription: mail\n---\n")}, + "notes/README.md": {Data: []byte("not a skill")}, + } + got := embeddedOfficialSkills(f) + if strings.Join(got, ",") != "lark-calendar,lark-mail" { + t.Errorf("embeddedOfficialSkills() = %v, want [lark-calendar lark-mail]", got) + } +} + +func TestUpdateNpm_Human_NothingInstalled_NoSkillsProgressLine(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, _, stderr := newTestFactory(t) + cmd := NewCmdUpdate(f) + cmd.SetArgs([]string{}) + + origFetch := fetchLatest + origVersion := currentVersion + t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion }) + fetchLatest = func() (string, error) { return "2.0.0", nil } + currentVersion = func() string { return "1.0.0" } + + origNew := newUpdater + t.Cleanup(func() { newUpdater = origNew }) + 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(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } + u.VerifyOverride = func(string) error { return nil } + u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult { + t.Error("skills index fetched although no official skill is installed") + return &selfupdate.NpmResult{Err: fmt.Errorf("unexpected index fetch")} + } + u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + if strings.Join(args, " ") == "-y skills ls -g --json" { + r.Stdout.WriteString(`[{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`) + } + return r + } + return u + } + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stderr.String() + if !strings.Contains(out, "Successfully updated") { + t.Errorf("expected success message in stderr, got: %s", out) + } + if strings.Contains(out, "Updating skills via") { + t.Errorf("progress line printed although skills sync was skipped: %s", out) + } + if !strings.Contains(out, "Skills not installed") || !strings.Contains(out, skillsInstallCommand) { + t.Errorf("expected not-installed notice with install command, got: %s", out) + } +} diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go index 4239a9e0df..8881fd4eb4 100644 --- a/internal/skillscheck/sync.go +++ b/internal/skillscheck/sync.go @@ -255,8 +255,19 @@ type SyncOptions struct { Force bool Runner SkillsRunner Now func() time.Time + // KnownOfficialSkills lists the official skill names embedded in this + // binary. It tells an official installation apart from a user skill that + // merely shares the lark- prefix; empty when the build embeds no skills. + KnownOfficialSkills []string } +// ActionNotInstalled reports that skills sync was skipped because no official +// skill is installed and no sync state exists. That combination means the user +// never installed skills (for example `npx @larksuite/cli install --no-skills`), +// so update must not install them uninvited. --force and an explicit +// --skills-layout are treated as a request to install and bypass the skip. +const ActionNotInstalled = "not_installed" + type SyncResult struct { Action string Official []string @@ -281,6 +292,7 @@ func SyncSkills(opts SyncOptions) *SyncResult { } previous, readable, err := ReadState() + stateMissing := err == nil && !readable if err != nil { readable = false previous = nil @@ -293,6 +305,9 @@ func SyncSkills(opts SyncOptions) *SyncResult { if err != nil { return &SyncResult{Action: "failed", Layout: targetLayout, Err: err} } + if skipNotInstalled(opts, stateMissing, installed) { + return &SyncResult{Action: ActionNotInstalled, Layout: targetLayout, Force: opts.Force} + } localOfficial, err := localOfficialSkills(installed, previous, readable) if err != nil { // A suite whose installed path or references cannot be read is treated as @@ -346,6 +361,45 @@ func SyncSkills(opts SyncOptions) *SyncResult { return fallbackSeparate(opts, previous, readable, localOfficial, installed, fallbackPlan, reasons) } +// skipNotInstalled is the only path that leaves skills alone: no sync state, +// no official skill installed, and no explicit request via --force or +// --skills-layout. Wizard installs write no state, so an installed official +// skill alone must keep syncing. +func skipNotInstalled(opts SyncOptions, stateMissing bool, installed []installedSkill) bool { + if !stateMissing || opts.Force || opts.Layout != "" { + return false + } + return !hasOfficialSkillInstalled(installed, opts.KnownOfficialSkills) +} + +// hasOfficialSkillInstalled decides offline so a CLI-only update never fetches +// the skills index just to skip. Installed names are matched against the +// official skills embedded in the binary plus the CLI-managed suite, so a user +// skill such as lark-custom does not count. A build without embedded skills +// falls back to the lark- prefix. +func hasOfficialSkillInstalled(installed []installedSkill, known []string) bool { + if len(known) == 0 { + return hasLarkPrefixedSkill(installed) + } + official := toSet(known) + official["lark-suite"] = true + for _, skill := range installed { + if official[skill.Name] { + return true + } + } + return false +} + +func hasLarkPrefixedSkill(installed []installedSkill) bool { + for _, skill := range installed { + if strings.HasPrefix(skill.Name, "lark-") { + return true + } + } + return false +} + func fetchOfficialSkills(runner SkillsRunner, source string) ([]string, error) { result := runner.FetchSkillsIndex(source) if result == nil || result.Err != nil { diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go index 9ce0512b4e..047da0492c 100644 --- a/internal/skillscheck/sync_test.go +++ b/internal/skillscheck/sync_test.go @@ -741,3 +741,101 @@ func assertStrings(t *testing.T, got, want []string) { t.Fatalf("got %#v, want %#v", got, want) } } + +func TestSyncSkillsNothingInstalledWithoutStateIsNotInstalled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + runner := &fakeSkillsRunner{ + sources: []string{"primary"}, + indexes: map[string]string{"primary": officialSkillsIndexOutput("lark-calendar", "lark-mail")}, + indexErrors: map[string]error{}, + installErrors: map[string]error{}, + stageErrors: map[string]error{}, + globalJSON: globalSkillsJSONOutput("custom-skill"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil || result.Action != ActionNotInstalled { + t.Fatalf("result = %+v, want action %q without error", result, ActionNotInstalled) + } + if len(runner.installs) != 0 { + t.Fatalf("installs = %v, want none", runner.installs) + } + if _, ok, err := ReadState(); ok || err != nil { + t.Fatalf("ReadState() = (_, %v, %v), want no state written", ok, err) + } +} + +func TestSyncSkillsNothingInstalledStillInstallsWhenRequested(t *testing.T) { + for _, test := range []struct { + name string + opts SyncOptions + seedState bool + installed []string + }{ + {name: "force", opts: SyncOptions{Force: true}}, + {name: "explicit layout", opts: SyncOptions{Layout: LayoutSeparate}}, + {name: "previous sync state", seedState: true}, + {name: "official skill installed", installed: []string{"lark-calendar"}}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if test.seedState { + if err := WriteState(SkillsState{Version: "1.0.32", Layout: LayoutSeparate, OfficialSkills: []string{"lark-calendar", "lark-mail"}}); err != nil { + t.Fatal(err) + } + } + runner := &fakeSkillsRunner{ + sources: []string{"primary"}, + indexes: map[string]string{"primary": officialSkillsIndexOutput("lark-calendar", "lark-mail")}, + indexErrors: map[string]error{}, + installErrors: map[string]error{}, + stageErrors: map[string]error{}, + globalJSON: globalSkillsJSONOutput(test.installed...), + } + opts := test.opts + opts.Version = "1.0.33" + opts.Runner = runner + opts.Now = time.Now + + result := SyncSkills(opts) + if result.Err != nil || result.Action != "synced" { + t.Fatalf("result = %+v, want synced without error", result) + } + assertStrings(t, runner.installs, []string{"primary:lark-calendar,lark-mail"}) + }) + } +} + +func TestSyncSkillsOfficialDetectionUsesKnownOfficialSkills(t *testing.T) { + for _, test := range []struct { + name string + known []string + installed []string + wantAction string + }{ + {name: "custom lark- prefixed skill is not official", known: []string{"lark-calendar", "lark-mail"}, installed: []string{"lark-custom"}, wantAction: ActionNotInstalled}, + {name: "known official skill syncs", known: []string{"lark-calendar", "lark-mail"}, installed: []string{"lark-calendar"}, wantAction: "synced"}, + {name: "suite counts as official", known: []string{"lark-calendar", "lark-mail"}, installed: []string{"lark-suite"}, wantAction: "synced"}, + {name: "no embedded list falls back to prefix", known: nil, installed: []string{"lark-custom"}, wantAction: "synced"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + runner := &fakeSkillsRunner{ + sources: []string{"primary"}, + indexes: map[string]string{"primary": officialSkillsIndexOutput("lark-calendar", "lark-mail")}, + indexErrors: map[string]error{}, + installErrors: map[string]error{}, + stageErrors: map[string]error{}, + globalJSON: globalSkillsJSONOutput(test.installed...), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now, KnownOfficialSkills: test.known}) + if result.Err != nil || result.Action != test.wantAction { + t.Fatalf("result = %+v, want action %q without error", result, test.wantAction) + } + if test.wantAction == ActionNotInstalled && len(runner.installs) != 0 { + t.Fatalf("installs = %v, want none", runner.installs) + } + }) + } +} diff --git a/scripts/install-wizard.js b/scripts/install-wizard.js index 1b887c6706..6a8e153760 100644 --- a/scripts/install-wizard.js +++ b/scripts/install-wizard.js @@ -35,6 +35,7 @@ const messages = { step2Spinner: "正在安装 Skills...", step2Done: "Skills 已安装", step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g", + step2SkipRequested: "已按 --no-skills 跳过 Skills 安装。需要时运行: npx skills add %s -y -g", step3: "正在配置应用...", step3NotFound: "未找到 lark-cli,终止", step3Found: "发现已配置应用 (App ID: %s),继续使用?", @@ -64,6 +65,7 @@ const messages = { step2Spinner: "Installing skills...", step2Done: "Skills installed", step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g", + step2SkipRequested: "Skipped skills installation (--no-skills). To install later: npx skills add %s -y -g", step3: "Configuring app...", step3NotFound: "lark-cli not found. Aborting", step3Found: "Found existing app (App ID: %s). Use this app?", @@ -216,6 +218,11 @@ function parseLangArg() { return null; } +/** True when a boolean flag such as --no-skills is present in process.argv. */ +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + // --------------------------------------------------------------------------- // Steps // --------------------------------------------------------------------------- @@ -271,7 +278,11 @@ async function skillsAlreadyInstalled() { } } -async function stepInstallSkills(msg) { +async function stepInstallSkills(msg, skip) { + if (skip) { + p.log.info(fmt(msg.step2SkipRequested, SKILLS_REPO_FALLBACK)); + return; + } const s = p.spinner(); s.start(msg.step2Spinner); try { @@ -363,18 +374,19 @@ async function main() { const isInteractive = !!process.stdin.isTTY; const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en"); const msg = messages[lang]; + const skipSkills = hasFlag("--no-skills"); if (isInteractive) { p.intro(msg.setup); await stepInstallGlobally(msg); - await stepInstallSkills(msg); + await stepInstallSkills(msg, skipSkills); await stepConfigInit(msg, lang); await stepAuthLogin(msg); p.outro(msg.done); } else { console.log(msg.setup); await stepInstallGlobally(msg); - await stepInstallSkills(msg); + await stepInstallSkills(msg, skipSkills); console.log(msg.nonTtyHint); } } diff --git a/scripts/install-wizard.test.js b/scripts/install-wizard.test.js new file mode 100644 index 0000000000..4cf575ec51 --- /dev/null +++ b/scripts/install-wizard.test.js @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { pathToFileURL } = require("node:url"); + +const runScript = path.join(__dirname, "run.js"); +const unixOnly = { skip: process.platform === "win32" && "fake npm/npx are POSIX shell scripts" }; + +// The wizard runs in non-interactive mode when stdin is not a TTY, which is +// the case under spawnSync. Fake npm reports the CLI as already installed at +// the latest version so step 1 is skipped, fake npx records every invocation +// so the test can prove whether the skills step ran, and @clack/prompts is +// replaced through a module hook because the script-test job never runs +// npm install. +function makeFixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "install-wizard-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const fakeBin = path.join(root, "fake-bin"); + const commandLog = path.join(root, "commands.log"); + fs.mkdirSync(fakeBin, { recursive: true }); + + const writeExecutable = (file, content) => fs.writeFileSync(file, content, { mode: 0o755 }); + writeExecutable(path.join(fakeBin, "npm"), `#!/bin/sh +case "$1" in + list) printf '%s\\n' '@larksuite/cli@1.0.0' ;; + view) printf '%s\\n' '1.0.0' ;; + prefix) printf '%s\\n' "$FAKE_NPM_PREFIX" ;; + *) exit 97 ;; +esac +`); + writeExecutable(path.join(fakeBin, "npx"), `#!/bin/sh +printf 'npx %s\\n' "$*" >> "$COMMAND_TEST_LOG" +printf '%s\\n' 'lark-existing' +`); + + const promptsStub = path.join(root, "prompts-stub.mjs"); + fs.writeFileSync(promptsStub, ` +const out = (message) => { if (message) console.log(message); }; +export const log = { info: out, success: out, error: out, warn: out, step: out, message: out }; +export function spinner() { return { start() {}, stop(message) { out(message); }, message() {} }; } +export function intro(message) { out(message); } +export function outro(message) { out(message); } +export function cancel(message) { out(message); } +export function isCancel() { return false; } +export async function select() { return "en"; } +export async function confirm() { return false; } +`); + const hooks = path.join(root, "prompts-hooks.mjs"); + fs.writeFileSync(hooks, ` +const stub = ${JSON.stringify(pathToFileURL(promptsStub).href)}; +export async function resolve(specifier, context, nextResolve) { + if (specifier === "@clack/prompts") return { url: stub, shortCircuit: true }; + return nextResolve(specifier, context); +} +`); + const loader = path.join(root, "prompts-loader.mjs"); + fs.writeFileSync(loader, ` +import { register } from "node:module"; +register(${JSON.stringify(pathToFileURL(hooks).href)}); +`); + + return { + commandLog, + loader, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + COMMAND_TEST_LOG: commandLog, + FAKE_NPM_PREFIX: root, + }, + }; +} + +function runInstall(fixture, args) { + return spawnSync( + process.execPath, + ["--import", pathToFileURL(fixture.loader).href, runScript, "install", ...args], + { env: fixture.env, encoding: "utf8", input: "" } + ); +} + +function readCommandLog(fixture) { + return fs.existsSync(fixture.commandLog) ? fs.readFileSync(fixture.commandLog, "utf8") : ""; +} + +describe("install wizard skills step", () => { + it("installs skills by default", unixOnly, (t) => { + const fixture = makeFixture(t); + const result = runInstall(fixture, ["--lang", "en"]); + + assert.equal(result.status, 0, result.stderr); + assert.match(readCommandLog(fixture), /^npx -y skills ls -g$/m); + assert.match(result.stdout, /Already installed\. Skipped/); + }); + + it("skips skills with --no-skills and never invokes npx", unixOnly, (t) => { + const fixture = makeFixture(t); + const result = runInstall(fixture, ["--lang", "en", "--no-skills"]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(readCommandLog(fixture), ""); + assert.match(result.stdout, /Skipped skills installation \(--no-skills\)/); + assert.match(result.stdout, /npx skills add larksuite\/cli -y -g/); + }); + + it("localizes the --no-skills notice", unixOnly, (t) => { + const fixture = makeFixture(t); + const result = runInstall(fixture, ["--no-skills", "--lang=zh"]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(readCommandLog(fixture), ""); + assert.match(result.stdout, /已按 --no-skills 跳过 Skills 安装/); + }); +});