Compare commits

..

11 Commits

Author SHA1 Message Date
guokexin.02
38d54119cd fix: merge main and adapt pnpm suite skills 2026-07-07 17:08:33 +08:00
guokexin.02
95fd8b3a5e test: improve lark suite skills coverage 2026-07-07 16:20:31 +08:00
guokexin.02
cefa041696 fix: remove standalone skills sync hint 2026-07-07 14:59:22 +08:00
guokexin.02
9ea42d1bc6 fix: filter stale flat skills after official discovery 2026-07-07 14:50:49 +08:00
guokexin.02
d134da8536 fix: use vfs recursion for suite skill copy 2026-07-07 12:56:23 +08:00
guokexin.02
84ac346b74 test: cover hybrid collection for new official skills 2026-07-07 12:42:19 +08:00
guokexin.02
f3017c291e fix: address lark suite review feedback 2026-07-07 11:43:02 +08:00
liangshuo-1
f0b6f35fee fix: resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
* fix(schema): fall back to runtime catalog when no embedded metadata

Binaries built from the bare Go module (plugin builds) embed only the
empty meta_data_default.json stub because meta_data.json is gitignored
and fetched at build time. The schema command, its completion, and the
affordance command-form resolver read the embedded-only catalog, so
every schema lookup failed with "Unknown service" even though the
runtime registry had already sync-fetched full metadata.

Add registry.SchemaCatalog(): embedded when compiled in (official
builds unchanged, still deterministic), otherwise the merged runtime
catalog seeded from cache or remote fetch. When neither source has
data (offline plugin build with a cold cache), schema now returns a
failed_precondition error with an actionable hint instead of
"Unknown service" with an empty candidate list.

* fix(registry): gate cached meta overlay on version newer than embedded

The cached remote meta was overlaid onto the embedded meta_data.json
unconditionally, so after a CLI upgrade an equal- or older-version
cache kept shadowing the freshly shipped embedded definitions until a
later refresh happened to rewrite it.

Only overlay when the cache version is strictly newer than the
embedded baseline. The bare-module stub baseline is "0.0.0", so plugin
builds without compiled metadata still take any real cached version
(TestOverlayGate_StubEmbedded_OverlaysRealCache) and the schema
runtime fallback keeps working offline from a warm cache.

Ports #1376 onto the typed meta model.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:24:24 +08:00
liangshuo-1
91d785f92f refactor(envvars): consolidate agent env value access (#1757)
* feat(envvars): add LARKSUITE_CLI_AGENT_NAME accessor

envvars.AgentName() reads LARKSUITE_CLI_AGENT_NAME so business code can identify the agent driving the CLI. Value is sanitised (trim, reject control chars, 128-byte cap) and returns "" when unset or invalid. Local-only; not sent to the server.

* refactor(envvars): consolidate agent env accessors, dedupe sanitize

Move agent-trace reading from cmdutil.AgentTraceValue into envvars.AgentTrace(), symmetric with AgentName and sharing one sanitizeSingleLine. secheader.go now only assembles headers (BaseSecurityHeaders calls envvars.AgentTrace()); the duplicated sanitize and its os/unicode imports are gone. The X-Agent-Trace header and its behaviour are unchanged.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:17:51 +08:00
leave330
e621c6e50f fix: support pnpm global installs in self-update (#1705) 2026-07-06 21:11:01 +08:00
guokexin.02
f151ca9ac1 feat: support lark suite hybrid skills layout 2026-07-03 17:43:52 +08:00
25 changed files with 2032 additions and 368 deletions

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd
}
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the same source as schema execution so completion candidates match
// what `schema` can resolve.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runSchema resolves the path through the embedded catalog and renders the
// runSchema resolves the path through the schema catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog()
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)

View File

@@ -86,10 +86,13 @@ 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
FlatSkills string
FlatSet bool
}
// NewCmdUpdate creates the update command.
@@ -102,12 +105,14 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- 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.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.FlatSet = cmd.Flags().Changed("flat-skills")
return updateRun(opts)
},
}
@@ -115,6 +120,8 @@ Use --check to only check for updates without installing.`,
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or hybrid")
cmd.Flags().StringVar(&opts.FlatSkills, "flat-skills", "", "comma-separated skills kept as top-level skills when the effective layout is hybrid")
cmdutil.SetRisk(cmd, "high-risk-write")
return cmd
@@ -122,6 +129,9 @@ Use --check to only check for updates without installing.`,
func updateRun(opts *UpdateOptions) error {
io := opts.Factory.IOStreams
if err := validateSkillsLayoutOptions(opts); err != nil {
return reportError(opts, io, "validation_error", err)
}
cur := currentVersion()
updater := newUpdater()
@@ -147,7 +157,7 @@ func updateRun(opts *UpdateOptions) error {
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -164,7 +174,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater)
}
return doNpmUpdate(opts, io, cur, latest, updater)
return doAutoUpdate(opts, io, cur, latest, detect, updater)
}
// --- Output helpers ---
@@ -208,7 +218,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
reason := detect.ManualReason()
if opts.JSON {
@@ -226,12 +236,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
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 detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
}
emitSkillsTextHints(io, skillsResult)
return nil
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
@@ -239,19 +260,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
}
npmResult := updater.RunNpmInstall(latest)
npmResult := install(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined),
"hint": permissionHint(combined, pm),
},
})
return output.ErrBare(output.ExitAPI)
@@ -263,7 +284,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" {
if hint := permissionHint(combined, pm); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
@@ -274,7 +295,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest)
hint := verificationFailureHint(updater, latest, pm)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
@@ -287,7 +308,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
if opts.JSON {
result := map[string]interface{}{
@@ -304,36 +325,50 @@ func doNpmUpdate(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 {
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
}
emitSkillsTextHints(io, skillsResult)
return nil
}
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
func permissionHint(pmOutput, pm string) string {
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return ""
}
return ""
if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
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))
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
if !force {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedFlat string, flatSet bool) *skillscheck.SyncResult {
layout, flat := resolveSkillsSyncOptions(requestedLayout, requestedFlat, flatSet)
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
if !force && !layoutExplicit && !flatSet {
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
}
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
Force: force,
Runner: updater,
Version: stateVersion,
Layout: layout,
FlatSkills: flat,
Force: force,
Runner: updater,
})
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
@@ -341,6 +376,38 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
return result
}
func validateSkillsLayoutOptions(opts *UpdateOptions) errs.TypedError {
if _, ok := skillscheck.NormalizeLayout(opts.SkillsLayout); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or hybrid").WithParam("--skills-layout")
}
return nil
}
func resolveSkillsSyncOptions(requestedLayout, requestedFlat string, flatSet bool) (string, []string) {
state, readable, err := skillscheck.ReadState()
if err != nil {
readable = false
state = nil
}
layout := skillscheck.LayoutSeparate
if strings.TrimSpace(requestedLayout) != "" {
layout, _ = skillscheck.NormalizeLayout(requestedLayout)
} else if readable && state != nil {
if stateLayout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
layout = stateLayout
}
}
if flatSet {
return layout, skillscheck.ParseFlatSkills(requestedFlat)
}
if readable && state != nil {
return layout, state.FlatSkills
}
return layout, []string{}
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
// already-up-to-date branch, including any skills_action / skills_warning
// fields derived from skillsResult. When check is true, this is the pure
@@ -387,6 +454,12 @@ func applySkillsStatus(env map[string]interface{}, target string) {
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
if state.Layout != "" {
status["layout"] = state.Layout
}
if len(state.FlatSkills) > 0 {
status["flat_skills"] = state.FlatSkills
}
env["skills_status"] = status
}
@@ -397,6 +470,7 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
env["skills_hint"] = skillsFailureHint()
env["skills_summary"] = skillsSummary(r)
default:
env["skills_action"] = "synced"
@@ -410,10 +484,17 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
"layout": r.Layout,
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
}
if len(r.Collected) > 0 {
summary["collected"] = r.Collected
}
if len(r.Flat) > 0 {
summary["flat"] = r.Flat
}
return summary
}
@@ -425,13 +506,17 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
if len(r.Failed) > 0 {
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")
fmt.Fprintf(io.ErrOut, " %s\n", skillsFailureHint())
case r.Force:
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
}
}
func skillsFailureHint() string {
return "Retry: lark-cli update --force. To switch to separate top-level skills: lark-cli update --skills-layout separate (this saves layout=separate and clears saved flat_skills)."
}

View File

@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -81,6 +102,113 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func TestUpdatePnpm_JSON(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 }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
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 }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(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 }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
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 }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
if strings.Contains(out, "pnpm dlx skills add larksuite/cli") {
t.Errorf("should not suggest standalone pnpm skills sync command, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
@@ -266,6 +394,9 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
@@ -600,8 +731,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if !strings.Contains(out, "skills will not be synced") {
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
}
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
if strings.Contains(out, "npx skills add larksuite/cli -y -g") {
t.Errorf("should not suggest standalone skills sync command, got: %s", out)
}
}
@@ -739,9 +870,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux: EACCES should produce a hint with npm prefix guidance.
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
@@ -749,16 +880,25 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied")
hint = permissionHint("EACCES: permission denied", "npm")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error"); got != "" {
if got := permissionHint("some other error", "npm"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}
@@ -996,7 +1136,7 @@ func newTestIO() *cmdutil.IOStreams {
func TestRunSkillsAndState_DedupHit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
t.Fatal(err)
}
called := false
@@ -1006,7 +1146,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got != nil {
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
@@ -1015,6 +1155,27 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
}
}
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want successful sync when state lacks layout", got)
}
if !called {
t.Error("SkillsCommandOverride not called, want resync when state lacks layout")
}
}
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 {
@@ -1027,7 +1188,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
@@ -1039,7 +1200,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
@@ -1050,6 +1211,12 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
if state.Layout != skillscheck.LayoutSeparate {
t.Errorf("state.Layout = %q, want %q", state.Layout, skillscheck.LayoutSeparate)
}
if len(state.FlatSkills) != 0 {
t.Errorf("state.FlatSkills = %#v, want empty", state.FlatSkills)
}
}
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
@@ -1064,7 +1231,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
return r
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
@@ -1077,6 +1244,50 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
}
}
func TestResolveSkillsSyncOptions_UsesStateAsFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.21",
Layout: skillscheck.LayoutHybrid,
FlatSkills: []string{"lark-doc"},
}); err != nil {
t.Fatal(err)
}
layout, flat := resolveSkillsSyncOptions("", "", false)
if layout != skillscheck.LayoutHybrid {
t.Fatalf("layout = %q, want %q", layout, skillscheck.LayoutHybrid)
}
if len(flat) != 1 || flat[0] != "lark-doc" {
t.Fatalf("flat = %#v, want [lark-doc]", flat)
}
}
func TestValidateSkillsLayoutOptionsRejectsSuiteMode(t *testing.T) {
err := validateSkillsLayoutOptions(&UpdateOptions{SkillsLayout: "suite"})
if err == nil {
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
}
if validation.Param != "--skills-layout" {
t.Fatalf("validation.Param = %q, want --skills-layout", validation.Param)
}
}
func TestValidateSkillsLayoutOptionsAllowsFlatSkillsFilteringAfterOfficialDiscovery(t *testing.T) {
err := validateSkillsLayoutOptions(&UpdateOptions{
SkillsLayout: skillscheck.LayoutHybrid,
FlatSkills: "lark-shared,lark-unknown",
FlatSet: true,
})
if err != nil {
t.Fatalf("validateSkillsLayoutOptions() err = %v, want nil", err)
}
}
func TestTruncate(t *testing.T) {
long := strings.Repeat("x", 3000)
got := selfupdate.Truncate(long, 2000)
@@ -1357,7 +1568,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
t.Cleanup(func() { syncSkills = origSync })
f, _, stderr := newTestFactory(t)
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}

View File

@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)

View File

@@ -6,12 +6,10 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -40,8 +38,6 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -49,25 +45,6 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h

View File

@@ -6,7 +6,6 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -19,6 +19,7 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

36
internal/envvars/read.go Normal file
View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

@@ -10,7 +10,6 @@ import (
"path/filepath"
"reflect"
"testing"
"time"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -52,7 +51,7 @@ func TestFileAtRevisionMissingClassifier(t *testing.T) {
}
func TestChangedFilesIncludingWorktree(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -84,7 +83,7 @@ func TestChangedFilesIncludingWorktree(t *testing.T) {
}
func TestChangedFilesHandlesWhitespacePaths(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -140,22 +139,3 @@ func gitOutput(t *testing.T, repo string, args ...string) string {
}
return string(out[:len(out)-1])
}
// newGitTestRepo returns a temp dir for a test-created git repo. Git tooling
// on this machine (trace2 hooks, etc.) can asynchronously write into a
// repo's .git/ shortly after a git command runs, racing with t.TempDir's
// automatic RemoveAll cleanup. Removing the tree ourselves first (retrying
// past that transient window) makes the later t.TempDir cleanup a no-op.
func newGitTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
t.Cleanup(func() {
for i := 0; i < 10; i++ {
if err := os.RemoveAll(repo); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
})
return repo
}

View File

@@ -10,11 +10,10 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -61,7 +60,7 @@ api_`+`key = "example-public-key"
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -93,7 +92,7 @@ func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -609,7 +608,7 @@ func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -700,7 +699,7 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
@@ -800,7 +799,7 @@ func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
func newGitRepo(t *testing.T) string {
t.Helper()
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -841,7 +840,7 @@ func requireFinding(t *testing.T, got []Finding, file, rule string) {
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
@@ -875,25 +874,6 @@ func runGitOutput(t *testing.T, repo string, args ...string) []byte {
return out
}
// newGitTestRepo returns a temp dir for a test-created git repo. Git tooling
// on this machine (trace2 hooks, etc.) can asynchronously write into a
// repo's .git/ shortly after a git command runs, racing with t.TempDir's
// automatic RemoveAll cleanup. Removing the tree ourselves first (retrying
// past that transient window) makes the later t.TempDir cleanup a no-op.
func newGitTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
t.Cleanup(func() {
for i := 0; i < 10; i++ {
if err := os.RemoveAll(repo); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
})
return repo
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

View File

@@ -11,7 +11,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
@@ -70,7 +69,7 @@ func TestReferenceCommandSurfaceNormalizesShortcutDomain(t *testing.T) {
}
func TestRunRequiresCommandIndexToCoverManifest(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
@@ -105,7 +104,7 @@ func TestRunRequiresCommandIndexToCoverManifest(t *testing.T) {
}
func TestRunReadsManifestFilesAndAcceptsServiceReferences(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -191,7 +190,7 @@ description: Manage Drive comments with service command references.
}
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -284,7 +283,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
}
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -324,7 +323,7 @@ func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
}
func TestLoadBaseReferenceManifestReadsCommandIndexGolden(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -365,7 +364,7 @@ func TestLoadBaseReferenceManifestReadsCommandIndexGolden(t *testing.T) {
}
func TestLoadBaseReferenceManifestRejectsEmptyGolden(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -385,7 +384,7 @@ func TestLoadBaseReferenceManifestRejectsEmptyGolden(t *testing.T) {
}
func TestLoadBaseReferenceManifestRejectsInvalidGoldenKind(t *testing.T) {
repo := newGitTestRepo(t)
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
@@ -607,22 +606,3 @@ func runGit(t *testing.T, repo string, args ...string) {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
// newGitTestRepo returns a temp dir for a test-created git repo. Git tooling
// on this machine (trace2 hooks, etc.) can asynchronously write into a
// repo's .git/ shortly after a git command runs, racing with t.TempDir's
// automatic RemoveAll cleanup. Removing the tree ourselves first (retrying
// past that transient window) makes the later t.TempDir cleanup a no-op.
func newGitTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
t.Cleanup(func() {
for i := 0; i < 10; i++ {
if err := os.RemoveAll(repo); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
})
return repo
}

View File

@@ -6,8 +6,7 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
// metadata — deterministic across machines, for golden tests and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
if cached, err := loadCachedMerged(); err == nil {
// After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
overlayMergedServices(cached)
}
}

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
}
// testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "test-1.0",
Version: "1.0.0",
Services: []meta.Service{
{
Name: name,
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
}
func TestCacheHit_WithinTTL(t *testing.T) {
resetInit()
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
}
func TestNetworkError_SilentDegradation(t *testing.T) {
resetInit()
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
if reg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", reg.Version)
}
}

View File

@@ -32,6 +32,7 @@ type InstallMethod int
const (
InstallNpm InstallMethod = iota
InstallPnpm
InstallManual
)
@@ -49,26 +50,38 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
)
// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
ResolvedPath string
NpmAvailable bool
Method InstallMethod
ResolvedPath string
NpmAvailable bool
PnpmAvailable bool
}
// CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool {
return d.Method == InstallNpm && d.NpmAvailable
switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
}
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string {
if d.Method == InstallNpm && !d.NpmAvailable {
switch {
case d.Method == InstallNpm && !d.NpmAvailable:
return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
}
return "not installed via npm"
return "not installed via npm or pnpm"
}
// NpmResult holds the result of an npm install or skills update execution.
@@ -92,6 +105,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
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
@@ -101,17 +115,38 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
}
// New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether
// npm is available for auto-update.
// DetectInstallMethod determines how the CLI was installed and whether the
// owning package manager is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil {
return u.DetectOverride()
}
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable()
if err != nil {
return DetectResult{Method: InstallManual}
@@ -120,24 +155,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe}
}
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual
if strings.Contains(resolved, "node_modules") {
method = InstallNpm
}
npmAvailable := false
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
if containsPnpmMarker(resolved) {
method = InstallPnpm
} else {
method = InstallNpm
}
}
return DetectResult{
Method: method,
ResolvedPath: resolved,
NpmAvailable: npmAvailable,
d := DetectResult{Method: method, ResolvedPath: resolved}
switch method {
case InstallNpm:
d.NpmAvailable = npmOnPath
case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
}
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
}
}
return false
}
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -163,6 +228,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
@@ -242,6 +330,14 @@ func (u *Updater) InstallAllSkills() *NpmResult {
return r
}
func (u *Updater) InstallSuiteSkill() *NpmResult {
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
if r.Err != nil {
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
}
return r
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}
@@ -261,19 +357,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...)
}
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
if err != nil {
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -208,6 +208,13 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
},
{
name: "install isolated suite skill",
run: func(u *Updater) *NpmResult {
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
},
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
},
}
for _, tt := range tests {
@@ -238,6 +245,76 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
called := []string{}
u := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), isolatedSkillsSourceURL) {
r.Err = fmt.Errorf("isolated source unavailable")
}
return r
},
}
result := u.InstallSuiteSkill()
if result.Err != nil {
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("calls = %#v, want primary and fallback", called)
}
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
t.Fatalf("primary call = %q, want isolated source", called[0])
}
if !strings.Contains(called[1], isolatedSkillsFallback) {
t.Fatalf("fallback call = %q, want isolated GitHub fallback", called[1])
}
}
func TestInstallSuiteSkillUsesPnpmDlxForIsolatedSources(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
logPath := filepath.Join(dir, "pnpm.log")
script := filepath.Join(dir, "pnpm")
scriptContent := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> %q
case "$*" in
*%q*) exit 1 ;;
*) exit 0 ;;
esac
`, logPath, isolatedSkillsSourceURL)
if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
u := New()
u.DetectOverride = func() DetectResult {
return DetectResult{Method: InstallPnpm, PnpmAvailable: true}
}
result := u.InstallSuiteSkill()
if result.Err != nil {
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
got := strings.Split(strings.TrimSpace(string(raw)), "\n")
want := []string{
"dlx skills add " + isolatedSkillsSourceURL + " -s lark-suite -g -y",
"dlx skills add " + isolatedSkillsFallback + " -s lark-suite -g -y",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("pnpm calls = %#v, want %#v", got, want)
}
}
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
@@ -371,3 +448,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}

View File

@@ -0,0 +1,389 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
const (
LayoutSeparate = "separate"
LayoutHybrid = "hybrid"
suiteSkillName = "lark-suite"
sharedSkillName = "lark-shared"
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
)
type GlobalSkillInfo struct {
Name string
Path string
}
func NormalizeLayout(layout string) (string, bool) {
switch strings.TrimSpace(layout) {
case "", LayoutSeparate:
return LayoutSeparate, true
case LayoutHybrid:
return LayoutHybrid, true
default:
return "", false
}
}
func ParseFlatSkills(value string) []string {
seen := map[string]bool{}
for _, part := range strings.Split(value, ",") {
name := strings.TrimSpace(part)
if name != "" {
seen[name] = true
}
}
return sortedKeys(seen)
}
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
infos, _ := parseGlobalSkillInfosJSON(text)
return infos
}
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
type globalSkill struct {
Name string `json:"name"`
Path string `json:"path"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil, false
}
seen := map[string]GlobalSkillInfo{}
for _, skill := range skills {
name := strings.TrimSpace(skill.Name)
path := strings.TrimSpace(skill.Path)
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
continue
}
seen[name] = GlobalSkillInfo{Name: name, Path: path}
}
out := make([]GlobalSkillInfo, 0, len(seen))
for _, info := range seen {
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, true
}
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
seen := map[string]bool{}
for _, info := range infos {
seen[info.Name] = true
if info.Name == suiteSkillName {
for _, subskill := range listSuiteSubskills(info.Path) {
seen[subskill] = true
}
}
}
return sortedKeys(seen)
}
func listSuiteSubskills(suitePath string) []string {
entries, err := vfs.ReadDir(filepath.Join(suitePath, "references", "subskills"))
if err != nil {
return nil
}
seen := map[string]bool{}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
if name != "" && skillNamePattern.MatchString(name) {
seen[name] = true
}
}
return sortedKeys(seen)
}
func normalOfficialSkills(skills []string) []string {
out := []string{}
for _, skill := range uniqueSorted(skills) {
if skill != suiteSkillName {
out = append(out, skill)
}
}
return out
}
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
if force || !stateReadable || previous == nil {
return []string{}
}
officialSet := toSet(official)
localSet := toSet(local)
deleted := map[string]bool{}
for _, skill := range previous.OfficialSkills {
if !officialSet[skill] || localSet[skill] {
continue
}
if layout != LayoutSeparate && skill == sharedSkillName {
continue
}
deleted[skill] = true
}
return sortedKeys(deleted)
}
func suiteEffectiveSkills(official []string, deleted map[string]bool) []string {
out := []string{}
for _, skill := range normalOfficialSkills(official) {
if !deleted[skill] {
out = append(out, skill)
}
}
return uniqueSorted(out)
}
func resolveHybridSkillSets(layout string, requestedFlat, official []string, skippedDeleted []string) ([]string, []string, error) {
if layout == LayoutSeparate {
return []string{}, []string{}, nil
}
officialSet := toSet(official)
deletedSet := toSet(skippedDeleted)
configuredFlat := map[string]bool{}
effectiveFlat := map[string]bool{}
for _, skill := range uniqueSorted(requestedFlat) {
if skill == sharedSkillName {
continue
}
if !officialSet[skill] {
continue
}
configuredFlat[skill] = true
if !deletedSet[skill] {
effectiveFlat[skill] = true
}
}
collected := []string{}
for _, skill := range normalOfficialSkills(official) {
if skill == sharedSkillName {
collected = append(collected, skill)
continue
}
if deletedSet[skill] || effectiveFlat[skill] {
continue
}
collected = append(collected, skill)
}
return sortedKeys(configuredFlat), uniqueSortedWithFirst(collected, sharedSkillName), nil
}
func uniqueSortedWithFirst(values []string, first string) []string {
seen := toSet(values)
if !seen[first] {
return sortedKeys(seen)
}
delete(seen, first)
return append([]string{first}, sortedKeys(seen)...)
}
func assembleSuiteLayout(layout string, collected []string, keepSharedTopLevel bool, infos []GlobalSkillInfo) error {
if layout == LayoutSeparate {
return nil
}
infoByName := map[string]GlobalSkillInfo{}
for _, info := range infos {
infoByName[info.Name] = info
}
suiteInfo, ok := infoByName[suiteSkillName]
if !ok {
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
}
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
if err := vfs.RemoveAll(subskillsDir); err != nil {
return err
}
if err := vfs.MkdirAll(subskillsDir, 0o755); err != nil {
return err
}
for _, skill := range collected {
info, ok := infoByName[skill]
if !ok {
return fmt.Errorf("suite subskill %q was not installed", skill)
}
dst := filepath.Join(subskillsDir, skill)
if keepSharedTopLevel && skill == sharedSkillName {
if err := copyDir(info.Path, dst); err != nil {
return err
}
continue
}
if err := moveDir(info.Path, dst); err != nil {
return err
}
}
return renderSuiteRoutes(suiteInfo.Path, collected)
}
func renderSuiteRoutes(suitePath string, collected []string) error {
skillPath := filepath.Join(suitePath, "SKILL.md")
data, err := vfs.ReadFile(skillPath)
if err != nil {
return err
}
text := normalizeSuiteTemplateText(string(data))
routes := []string{}
for _, skill := range collected {
desc := skillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
if desc == "" {
desc = skill
}
routes = append(routes, fmt.Sprintf("- %s: %s", skill, desc))
}
if !strings.Contains(text, suiteRoutesPlaceholder) {
return fmt.Errorf("%s route placeholder not found", suiteSkillName)
}
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
return vfs.WriteFile(skillPath, []byte(text), 0o644)
}
func normalizeSuiteTemplateText(text string) string {
text = strings.ReplaceAll(text, "--collected-skills", "--flat-skills")
oldShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
newShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。"
return strings.ReplaceAll(text, oldShared, newShared)
}
func skillDescription(path string) string {
data, err := vfs.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(string(data), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return ""
}
for i, line := range lines[1:] {
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
return ""
}
if strings.HasPrefix(trimmed, "description:") {
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
if value == ">" || value == "|" {
return foldedYAMLScalar(lines[i+2:])
}
return strings.Trim(value, `"'`)
}
}
return ""
}
func foldedYAMLScalar(lines []string) string {
parts := []string{}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed == "---" || !isIndentedYAMLLine(line) {
break
}
parts = append(parts, trimmed)
}
return strings.Join(parts, " ")
}
func isIndentedYAMLLine(line string) bool {
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
}
func moveDir(src, dst string) error {
if err := vfs.RemoveAll(dst); err != nil {
return err
}
if err := vfs.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := vfs.Rename(src, dst); err == nil {
return nil
}
if err := copyDir(src, dst); err != nil {
return err
}
return vfs.RemoveAll(src)
}
func copyDir(src, dst string) error {
info, err := vfs.Stat(src)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("%s is not a directory", src)
}
if err := vfs.RemoveAll(dst); err != nil {
return err
}
return copyDirEntries(src, dst)
}
func copyDirEntries(src, dst string) error {
if err := vfs.MkdirAll(dst, 0o755); err != nil {
return err
}
entries, err := vfs.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDirEntries(srcPath, dstPath); err != nil {
return err
}
continue
}
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := vfs.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := vfs.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := vfs.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}

View File

@@ -23,10 +23,12 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
type SkillsState struct {
Version string `json:"version"`
Layout string `json:"layout,omitempty"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
FlatSkills []string `json:"flat_skills"`
UpdatedAt string `json:"updated_at"`
}
@@ -76,6 +78,14 @@ func ReadSyncedVersion() (string, bool) {
return state.Version, true
}
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
state, readable, err := ReadState()
if err != nil || !readable || state.Version == "" {
return "", "", false
}
return state.Version, state.Layout, true
}
func (s *SkillsState) ensureNonNilSlices() {
if s.OfficialSkills == nil {
s.OfficialSkills = []string{}
@@ -89,4 +99,7 @@ func (s *SkillsState) ensureNonNilSlices() {
if s.SkippedDeletedSkills == nil {
s.SkippedDeletedSkills = []string{}
}
if s.FlatSkills == nil {
s.FlatSkills = []string{}
}
}

View File

@@ -32,10 +32,12 @@ func TestReadState_Valid(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
want := SkillsState{
Version: "1.2.3",
Layout: LayoutHybrid,
OfficialSkills: []string{"lark-doc", "lark-im"},
UpdatedSkills: []string{"lark-doc"},
AddedOfficialSkills: []string{"lark-task"},
SkippedDeletedSkills: []string{"custom-skill"},
FlatSkills: []string{"lark-doc"},
UpdatedAt: "2026-05-18T10:00:00Z",
}
data, err := json.Marshal(want)
@@ -115,6 +117,9 @@ func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
if got.SkippedDeletedSkills == nil {
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
}
if got.FlatSkills == nil {
t.Fatal("flat_skills decoded as nil, want empty slice")
}
}
func TestReadSyncedVersionFromState(t *testing.T) {
@@ -137,3 +142,24 @@ func TestReadSyncedVersionFromState(t *testing.T) {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
}
}
func TestReadSyncedVersionAndLayoutFromState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if version, layout, ok := ReadSyncedVersionAndLayout(); ok || version != "" || layout != "" {
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want empty result for missing state", version, layout, ok)
}
if err := WriteState(SkillsState{Version: "1.2.3", Layout: LayoutHybrid}); err != nil {
t.Fatal(err)
}
if version, layout, ok := ReadSyncedVersionAndLayout(); !ok || version != "1.2.3" || layout != LayoutHybrid {
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want (\"1.2.3\", %q, true)", version, layout, ok, LayoutHybrid)
}
if err := WriteState(SkillsState{Layout: LayoutHybrid}); err != nil {
t.Fatal(err)
}
if version, layout, ok := ReadSyncedVersionAndLayout(); ok || version != "" || layout != "" {
t.Fatalf("ReadSyncedVersionAndLayout() = (%q, %q, %v), want empty result for empty version", version, layout, ok)
}
}

View File

@@ -21,6 +21,7 @@ var (
type SyncInput struct {
Version string
Layout string
OfficialSkills []string
LocalSkills []string
PreviousState *SkillsState
@@ -195,7 +196,18 @@ func parseOfficialSkillsList(lines []string) []string {
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
official := normalOfficialSkills(input.OfficialSkills)
layout, _ := NormalizeLayout(input.Layout)
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
if layout != LayoutSeparate {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: suiteEffectiveSkills(official, toSet(skippedDeleted)),
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
SkippedDeleted: skippedDeleted,
}
}
if input.Force {
return SyncPlan{
Version: input.Version,
@@ -208,19 +220,7 @@ func PlanSync(input SyncInput) SyncPlan {
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{}
for _, skill := range official {
if !previousSet[skill] {
newAddedOfficial = append(newAddedOfficial, skill)
}
}
newAddedOfficial := newlyOfficialSkills(official, input.PreviousState, input.StateReadable)
updateSet := toSet(installedOfficial)
for _, skill := range newAddedOfficial {
@@ -229,19 +229,12 @@ func PlanSync(input SyncInput) SyncPlan {
toUpdate := sortedKeys(updateSet)
updateSet = toSet(toUpdate)
skipped := []string{}
for _, skill := range official {
if !updateSet[skill] {
skipped = append(skipped, skill)
}
}
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: toUpdate,
Added: uniqueSorted(newAddedOfficial),
SkippedDeleted: skipped,
Added: newAddedOfficial,
SkippedDeleted: skippedDeleted,
}
}
@@ -252,13 +245,16 @@ type SkillsRunner interface {
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
InstallSuiteSkill() *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Force bool
Runner SkillsRunner
Now func() time.Time
Version string
Layout string
FlatSkills []string
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
@@ -271,6 +267,9 @@ type SyncResult struct {
Err error
Detail string
Force bool
Layout string
Flat []string
Collected []string
}
func SyncSkills(opts SyncOptions) *SyncResult {
@@ -280,16 +279,26 @@ func SyncSkills(opts SyncOptions) *SyncResult {
if opts.Runner == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
layout, ok := NormalizeLayout(opts.Layout)
if !ok {
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
}
// --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
}
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
}
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
}
@@ -302,12 +311,17 @@ func SyncSkills(opts SyncOptions) *SyncResult {
plan := PlanSync(SyncInput{
Version: opts.Version,
Layout: layout,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
flat, collected, err := resolveHybridSkillSets(layout, opts.FlatSkills, plan.OfficialSkills, plan.SkippedDeleted)
if err != nil {
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
}
result := &SyncResult{
Action: "synced",
@@ -316,26 +330,60 @@ func SyncSkills(opts SyncOptions) *SyncResult {
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
Layout: layout,
Flat: flat,
Collected: collected,
}
if len(plan.ToUpdate) == 0 {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
}
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {
if layout != LayoutSeparate {
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
}
return fallbackFullInstall(opts, resultDetail(installResult), official)
}
}
if layout != LayoutSeparate {
installSuiteResult := opts.Runner.InstallSuiteSkill()
if installSuiteResult == nil || installSuiteResult.Err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
result.Detail = resultDetail(installSuiteResult)
return result
}
infosResult := opts.Runner.ListGlobalSkillsJSON()
if infosResult == nil || infosResult.Err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
result.Detail = resultDetail(infosResult)
return result
}
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
keepSharedTopLevel := layout == LayoutHybrid && len(flat) > 0
if err := assembleSuiteLayout(layout, collected, keepSharedTopLevel, infos); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
return result
}
}
state := SkillsState{
Version: opts.Version,
Layout: layout,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().Format(time.RFC3339),
FlatSkills: stateFlatSkills(layout, flat),
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
@@ -346,6 +394,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
return &SyncResult{
Action: "failed",
Err: err,
Detail: detail,
Force: force,
Layout: layout,
}
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
@@ -383,8 +441,9 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
if valid {
return installedSkillNamesFromInfos(infos), true
}
}
@@ -411,6 +470,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
Detail: reason,
Force: opts.Force,
Layout: LayoutSeparate,
}
}
if installResult.Err != nil {
@@ -419,16 +479,18 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
Detail: reason + "\n" + resultDetail(installResult),
Force: opts.Force,
Layout: LayoutSeparate,
}
}
state := SkillsState{
Version: opts.Version,
Layout: LayoutSeparate,
OfficialSkills: official,
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().Format(time.RFC3339),
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{
@@ -439,6 +501,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
SkippedDeleted: []string{},
Detail: reason + "\nstate write failed: " + writeErr.Error(),
Force: opts.Force,
Layout: LayoutSeparate,
}
}
@@ -450,9 +513,38 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
SkippedDeleted: []string{},
Detail: reason,
Force: opts.Force,
Layout: LayoutSeparate,
}
}
func stateFlatSkills(layout string, requested []string) []string {
if layout != LayoutHybrid {
return []string{}
}
out := []string{}
for _, skill := range uniqueSorted(requested) {
if skill != sharedSkillName {
out = append(out, skill)
}
}
return out
}
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
previousOfficial := []string{}
if stateReadable && previous != nil {
previousOfficial = previous.OfficialSkills
}
previousSet := toSet(previousOfficial)
added := []string{}
for _, skill := range official {
if !previousSet[skill] {
added = append(added, skill)
}
}
return uniqueSorted(added)
}
func resultDetail(result *selfupdate.NpmResult) string {
if result == nil {
return ""

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
@@ -43,6 +44,14 @@ func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
}
}
func TestParseFlatSkillsTrimsDeduplicatesAndSorts(t *testing.T) {
got := ParseFlatSkills(" lark-doc, lark-im,,lark-doc, lark-base ")
want := []string{"lark-base", "lark-doc", "lark-im"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseFlatSkills() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
@@ -216,8 +225,10 @@ type fakeSkillsRunner struct {
globalErr error
installErr error
installAllErr error
installSuiteErr error
installed [][]string
installedAll int
installedSuite int
listedIndex int
listedOfficial int
listedGlobalJSON int
@@ -319,6 +330,13 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
return r
}
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
f.installedSuite++
r := &selfupdate.NpmResult{}
r.Err = f.installSuiteErr
return r
}
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -472,6 +490,34 @@ func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *t
}
}
func TestSyncSkills_HybridOfficialDiscoveryFailureDoesNotFallback(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list unavailable"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
Runner: runner,
Now: time.Now,
})
if result.Action != "failed" {
t.Fatalf("SyncSkills() action = %q, want failed", result.Action)
}
if result.Layout != LayoutHybrid {
t.Fatalf("SyncSkills() layout = %q, want %q", result.Layout, LayoutHybrid)
}
if result.Err == nil || !strings.Contains(result.Err.Error(), "failed to discover official skills for hybrid layout") {
t.Fatalf("SyncSkills() err = %v, want hybrid discovery failure", result.Err)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -574,7 +620,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
@@ -585,14 +631,15 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
if len(runner.installed) != 1 {
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
@@ -697,6 +744,200 @@ func TestSyncSkills_NilRunnerFails(t *testing.T) {
}
}
func TestSyncSkills_HybridAssemblesSuiteAndMovesCollectedSkills(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
paths := map[string]string{}
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
paths[name] = filepath.Join(dir, name)
writeTestSkill(t, paths[name], name)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
globalJSONOut: globalSkillsJSONFromPaths(paths),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
FlatSkills: []string{"lark-calendar"},
Runner: runner,
Now: time.Now,
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if runner.installedSuite != 1 {
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
}
assertStrings(t, result.Flat, []string{"lark-calendar"})
assertStrings(t, result.Collected, []string{"lark-shared", "lark-doc"})
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-doc", "SKILL.md")); err != nil {
t.Fatalf("suite lark-doc missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
t.Fatalf("suite lark-shared missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-calendar"], "SKILL.md")); err != nil {
t.Fatalf("flat lark-calendar missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-doc"], "SKILL.md")); !os.IsNotExist(err) {
t.Fatalf("collected lark-doc still exists at top level or unexpected err: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); err != nil {
t.Fatalf("lark-shared should stay top-level when flat set is non-empty: %v", err)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Layout != LayoutHybrid {
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutHybrid)
}
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
}
func TestSyncSkills_HybridCollectsNewOfficialSkillOnNextUpdate(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
paths := map[string]string{}
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
paths[name] = filepath.Join(dir, name)
writeTestSkill(t, paths[name], name)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
globalJSONOut: globalSkillsJSONFromPaths(paths),
}
first := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
FlatSkills: []string{"lark-calendar"},
Runner: runner,
Now: time.Now,
})
if first.Err != nil {
t.Fatalf("first SyncSkills() err = %v, want nil", first.Err)
}
paths["lark-new"] = filepath.Join(dir, "lark-new")
for _, name := range []string{"lark-doc", "lark-new", "lark-shared", "lark-suite"} {
writeTestSkill(t, paths[name], name)
}
runner.officialIndexOut = officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-new", "lark-shared")
runner.globalJSONOut = globalSkillsJSONFromPaths(paths)
second := SyncSkills(SyncOptions{
Version: "1.0.34",
Layout: LayoutHybrid,
FlatSkills: []string{"lark-calendar"},
Runner: runner,
Now: time.Now,
})
if second.Err != nil {
t.Fatalf("second SyncSkills() err = %v, want nil", second.Err)
}
assertStrings(t, second.Added, []string{"lark-new"})
assertStrings(t, second.Flat, []string{"lark-calendar"})
assertStrings(t, second.Collected, []string{"lark-shared", "lark-doc", "lark-new"})
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-new", "SKILL.md")); err != nil {
t.Fatalf("suite lark-new missing: %v", err)
}
if _, err := os.Stat(filepath.Join(paths["lark-new"], "SKILL.md")); !os.IsNotExist(err) {
t.Fatalf("new official skill should be collected, top-level path err: %v", err)
}
data, err := os.ReadFile(filepath.Join(paths["lark-suite"], "SKILL.md"))
if err != nil {
t.Fatalf("read suite SKILL.md: %v", err)
}
if !strings.Contains(string(data), "- lark-new: lark-new description") {
t.Fatalf("suite SKILL.md missing lark-new route:\n%s", data)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-doc", "lark-new", "lark-shared"})
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
}
func TestSyncSkills_HybridWithNoFlatSkillsDoesNotKeepSharedTopLevel(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
paths := map[string]string{}
for _, name := range []string{"lark-shared", "lark-suite"} {
paths[name] = filepath.Join(dir, name)
writeTestSkill(t, paths[name], name)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-shared"),
globalJSONOut: globalSkillsJSONFromPaths(paths),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
Runner: runner,
Now: time.Now,
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); !os.IsNotExist(err) {
t.Fatalf("lark-shared should not stay top-level when flat set is empty; err: %v", err)
}
if result.Flat == nil || len(result.Flat) != 0 {
t.Fatalf("result.Flat = %#v, want empty slice", result.Flat)
}
}
func TestSyncSkills_HybridFiltersNonFlatOfficialSkills(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
paths := map[string]string{}
for _, name := range []string{"lark-calendar", "lark-shared", "lark-suite"} {
paths[name] = filepath.Join(dir, name)
writeTestSkill(t, paths[name], name)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
globalJSONOut: globalSkillsJSONFromPaths(paths),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Layout: LayoutHybrid,
FlatSkills: []string{"lark-calendar", "lark-missing", "lark-shared"},
Runner: runner,
Now: time.Now,
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Flat, []string{"lark-calendar"})
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
}
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -733,6 +974,81 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
}
}
func TestNormalizeSuiteTemplateTextRewritesLegacyFlatSkillWording(t *testing.T) {
input := "`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
got := normalizeSuiteTemplateText(input)
if strings.Contains(got, "--collected-skills") {
t.Fatalf("normalized text still contains legacy flag: %s", got)
}
if !strings.Contains(got, "--flat-skills") {
t.Fatalf("normalized text missing --flat-skills: %s", got)
}
if !strings.Contains(got, "只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份") {
t.Fatalf("normalized text missing current lark-shared rule: %s", got)
}
}
func TestSkillDescriptionSupportsFoldedYAMLScalar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "SKILL.md")
content := `---
name: lark-whiteboard
description: >
飞书画板:查询和编辑飞书云文档中的画板。
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。
metadata:
requires:
bins: ["lark-cli"]
---
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
got := skillDescription(path)
want := "飞书画板:查询和编辑飞书云文档中的画板。 当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。"
if got != want {
t.Fatalf("skillDescription() = %q, want %q", got, want)
}
}
func TestAssembleSuiteLayoutSeparateIsNoop(t *testing.T) {
if err := assembleSuiteLayout(LayoutSeparate, []string{"lark-doc"}, false, nil); err != nil {
t.Fatalf("assembleSuiteLayout(separate) err = %v, want nil", err)
}
}
func TestAssembleSuiteLayoutMissingSuiteReturnsError(t *testing.T) {
err := assembleSuiteLayout(LayoutHybrid, []string{"lark-doc"}, false, []GlobalSkillInfo{
{Name: "lark-doc", Path: filepath.Join(t.TempDir(), "lark-doc")},
})
if err == nil || !strings.Contains(err.Error(), "lark-suite") {
t.Fatalf("assembleSuiteLayout() err = %v, want missing lark-suite error", err)
}
}
func TestCopyDirCopiesNestedFiles(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src")
dst := filepath.Join(root, "dst")
if err := os.MkdirAll(filepath.Join(src, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "nested", "file.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := copyDir(src, dst); err != nil {
t.Fatalf("copyDir() err = %v, want nil", err)
}
got, err := os.ReadFile(filepath.Join(dst, "nested", "file.txt"))
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("copied file = %q, want hello", string(got))
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
@@ -740,6 +1056,38 @@ func assertStrings(t *testing.T, got, want []string) {
}
}
func writeTestSkill(t *testing.T, dir, name string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
content := fmt.Sprintf("---\nname: %s\ndescription: %s description\n---\n", name, name)
if name == suiteSkillName {
content = "---\nname: lark-suite\ndescription: Lark suite\n---\n<!-- LARK_SUITE_ROUTES -->\n"
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func globalSkillsJSONFromPaths(paths map[string]string) string {
names := make([]string, 0, len(paths))
for name := range paths {
names = append(names, name)
}
sort.Strings(names)
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, paths[name])
}
b.WriteString("]")
return b.String()
}
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
@@ -853,65 +1201,3 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
}
}
func TestSyncSkills_WritesLocalTimezoneUpdatedAt(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
localZone := time.FixedZone("UTC+8", 8*60*60)
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, localZone) },
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
want := "2026-05-18T12:00:00+08:00"
if state.UpdatedAt != want {
t.Fatalf("state.UpdatedAt = %q, want %q (local timezone offset, not UTC Z-suffix)", state.UpdatedAt, want)
}
}
func TestSyncSkills_FallbackWritesLocalTimezoneUpdatedAt(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
localZone := time.FixedZone("UTC+8", 8*60*60)
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, localZone) },
})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
want := "2026-05-18T12:00:00+08:00"
if state.UpdatedAt != want {
t.Fatalf("state.UpdatedAt = %q, want %q (local timezone offset, not UTC Z-suffix)", state.UpdatedAt, want)
}
}

View File

@@ -0,0 +1,33 @@
---
name: lark-suite
version: 0.1.0
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
metadata:
requires:
bins:
- lark-cli
---
# Lark Suite
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
## 使用流程
1. 根据用户意图从下方路由表选择一个或多个子能力即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
2. 仅使用本文件列出的路由与对应子能力入口,不要遍历或探测其他技能目录。
3. 如果目标能力未列出,返回无法路由的明确提示。
4. 仅读取当前已选子能力明确要求的前置文件。
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`
`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
## 能力路由
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
<!-- LARK_SUITE_ROUTES -->