Compare commits

...

9 Commits

Author SHA1 Message Date
liangshuo-1
b783561965 chore(release): v1.0.41 (#1108)
Change-Id: I3559c31109a5a5a7c3cfc3e54f60aff4043bfefc
2026-05-26 20:54:54 +08:00
fangshuyu-768
f00261da9f fix(drive): support doubao drive inspect URL variants (#1106) 2026-05-26 19:51:47 +08:00
zhangheng023
137176e8b0 fix: sync skills incrementally during update (#1042) 2026-05-26 19:23:08 +08:00
zhangjun-bytedance
0bf590d01a feat: get minutes keywords (#1079)
Parse keywords from minutes artifacts API in vc +notes and document
the field in lark-vc skill references.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 18:42:29 +08:00
calendar-assistant
cf40945bbc feat(minutes): add minutes edit shortcuts (#1036) 2026-05-26 18:41:50 +08:00
fangshuyu-768
b9e5b50251 docs(skills): fix agent routing for doubao.com URLs (#1082) 2026-05-26 17:41:26 +08:00
ILUO
049ddf771b docs(task): require --complete=false for pending standup summaries (#1101)
The standup workflow and the +get-my-tasks reference both implied a
"pending todo summary" use case but did not pass --complete=false in
the example commands. As a result, completed tasks were surfaced into
standup/daily summaries as if they were still pending.

This change updates the workflow and reference docs only — the
underlying command behavior is unchanged.

Closes #993
2026-05-26 16:56:40 +08:00
AlbertSun
f12d279fc2 feat: add config keychain-downgrade subcommand (macOS) (#1085)
* feat(config): add command to explicitly dowgrade keychain storage to use file

* feat(config): add command to explicitly dowgrade keychain storage to use file

* fix(lint): use the corresponding vfs.Xxx() from internal/vfs

* fix: optimize scanError && osReadDir

* opt: remove CmdConfigKeychainDowngrade wrapper & runF

* fix: add downgrade hint on keychain blocked

* opt: remove redundant ErrOrphanedCredentials

* opt: fix suggested concurrent platformSet issue
2026-05-26 16:20:33 +08:00
liangshuo-1
83adbac2b2 docs: clarify contributor guidance (#1096)
(cherry picked from commit 406e0dee6a)

Co-authored-by: JulyanXu <1581085037@qq.com>
2026-05-26 15:51:00 +08:00
52 changed files with 3327 additions and 463 deletions

View File

@@ -9,7 +9,7 @@
## Test Plan
<!-- Describe how this change was verified. -->
- [ ] Unit tests pass
- [ ] Manual local verification confirms the `lark xxx` command works as expected
- [ ] Manual local verification confirms the `lark-cli <domain> <command>` flow works as expected
## Related Issues
<!-- Link related issues. Use Closes/Fixes to close them automatically. -->

View File

@@ -2,6 +2,32 @@
All notable changes to this project will be documented in this file.
## [v1.0.41] - 2026-05-26
### Features
- **minutes**: Add minutes edit shortcuts (#1036)
- **minutes**: Get minutes keywords (#1079)
- **slides**: Support importing pptx as slides (#1068)
- **config**: Add `keychain-downgrade` subcommand (macOS) (#1085)
- **errors**: Add structured CLI error contract (#984)
- **apps**: Replace `+html-publish` cwd hard-reject with credential-file scan (#1072)
### Bug Fixes
- **drive**: Support doubao drive inspect URL variants (#1106)
- **skills**: Sync skills incrementally during update (#1042)
- **apps**: Read app object from `data.app` for `+create` and `+update` (#1087)
- **common**: Escape special chars in multipart form filenames (#1037)
- **auth**: Remove fenced code block guidance from auth URL output hints (#1088)
### Documentation
- **skills**: Fix agent routing for doubao.com URLs (#1082)
- **task**: Require `--complete=false` for pending standup summaries (#1101)
- **base**: Document UI-only field settings (#1078)
- **contributing**: Clarify contributor guidance (#1096)
## [v1.0.40] - 2026-05-25
### Features
@@ -860,6 +886,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38

View File

@@ -279,6 +279,8 @@ Community contributions are welcome! If you find a bug or have feature suggestio
For major changes, we recommend discussing with us first via an Issue.
Before opening a PR, see [AGENTS.md](./AGENTS.md) for the local build, test, and PR checklist used by contributors and AI agents.
## License
This project is licensed under the **MIT License**.

View File

@@ -280,6 +280,8 @@ lark-cli schema im.messages.delete
对于较大的改动,建议先通过 Issue 与我们讨论。
提交 PR 前,请先阅读 [AGENTS.md](./AGENTS.md),其中列出了贡献者和 AI Agent 使用的本地构建、测试和 PR 检查清单。
## 许可证
本项目基于 **MIT 许可证** 开源。

View File

@@ -33,6 +33,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
return cmd
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin
package config
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigKeychainDowngrade creates the macOS-only subcommand that pins
// the master key to the local file fallback (master.key.file) so subsequent
// operations bypass the OS Keychain. Useful inside sandboxes like Codex
// where the system Keychain is unreachable.
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "keychain-downgrade",
Short: "Downgrade keychain storage to a local file (macOS only)",
Long: `Materialize the master key from the macOS system Keychain into a local file
under ~/Library/Application Support/lark-cli/master.key.file, then pin all
subsequent reads to that file.
Intended workflow: run this once from an interactive Terminal session on
macOS (where the system Keychain is reachable). After it finishes,
sandboxed / automation / CI runs of lark-cli on the same machine will read
the master key from the local file and no longer need the OS Keychain.
This is the supported fix for environments like the Codex sandbox where the
system Keychain is blocked. Running keychain-downgrade from inside such a
sandbox will itself fail with "keychain access blocked" — that is expected;
run it from an interactive macOS session instead.
The OS Keychain entry is preserved as a cold backup; nothing is deleted there.
The command is idempotent: re-running it on an already-downgraded install
reports "already downgraded" and exits 0.`,
RunE: func(cmd *cobra.Command, args []string) error {
return configKeychainDowngradeRun(f)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}
func configKeychainDowngradeRun(f *cmdutil.Factory) error {
service := keychain.LarkCliService
keyPath := keychain.MasterKeyFilePath(service)
result, err := keychain.DowngradeMasterKeyToFile(service)
if err != nil {
return output.ErrWithHint(
output.ExitAPI,
"config",
fmt.Sprintf("keychain downgrade failed: %v", err),
"This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.",
)
}
switch result {
case keychain.DowngradeAlreadyDone:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("keychain already downgraded; subsequent operations read from %s", keyPath))
case keychain.DowngradeUsedKeychainKey:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("downgraded: copied master key from system Keychain to %s. Subsequent operations will read from file, bypassing the OS Keychain (useful inside sandboxes like Codex).", keyPath))
case keychain.DowngradeCreatedNewKey:
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("system Keychain was empty; generated a new master key and wrote it to %s. The OS Keychain was not modified.", keyPath))
}
return nil
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !darwin
package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigKeychainDowngrade is registered on all platforms so that
// `lark-cli config --help` reads the same everywhere. On non-macOS it
// refuses with a clear message.
func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
_ = f
cmd := &cobra.Command{
Use: "keychain-downgrade",
Short: "Downgrade keychain storage to a local file (macOS only)",
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
RunE: func(cmd *cobra.Command, args []string) error {
return output.ErrValidation("keychain-downgrade is only supported on macOS")
},
}
return cmd
}

View File

@@ -384,11 +384,8 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
})
}
// TestSetupNotices_ColdStart_NoNotice verifies that a missing stamp
// produces no skills key in the composed notice. Users who installed
// skills via `npx skills add` (no stamp) must not see the misleading
// "not installed" notice — only `lark-cli update` users opt into the
// drift tracker.
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
// produces no skills key in the composed notice.
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
@@ -419,13 +416,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
}
}
// TestSetupNotices_InSync verifies that a matching stamp produces no
// TestSetupNotices_InSync verifies that matching state produces no
// skills key in the composed notice.
func TestSetupNotices_InSync(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
@@ -452,13 +449,13 @@ func TestSetupNotices_InSync(t *testing.T) {
}
}
// TestSetupNotices_Drift verifies a mismatching stamp produces the
// TestSetupNotices_Drift verifies mismatching state produces the
// drift message with both current and target populated.
func TestSetupNotices_Drift(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
@@ -507,7 +504,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}

View File

@@ -31,15 +31,18 @@ var (
currentVersion = func() string { return build.Version }
currentOS = runtime.GOOS
newUpdater = func() *selfupdate.Updater { return selfupdate.New() }
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult { return skillscheck.SyncSkills(opts) }
)
func isWindows() bool { return currentOS == osWindows }
// normalizeVersion canonicalizes a version string for stamp comparison.
// normalizeVersion canonicalizes a version string for state comparison.
// Strips a leading "v" so versions written from Makefile (git describe →
// "v1.0.0") and npm (no prefix → "1.0.0") compare equal.
func normalizeVersion(s string) string {
return strings.TrimPrefix(strings.TrimSpace(s), "v")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
return strings.TrimPrefix(s, "V")
}
func releaseURL(version string) string {
@@ -121,7 +124,9 @@ func updateRun(opts *UpdateOptions) error {
cur := currentVersion()
updater := newUpdater()
updater.CleanupStaleFiles()
if !opts.Check {
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
// 1. Fetch latest version
@@ -137,13 +142,9 @@ func updateRun(opts *UpdateOptions) error {
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
// Run skills sync before returning — covers the case where the
// binary is already current but skills were never synced.
// Stamp dedup makes this a no-op if skills are already in sync.
// Skip side-effects under --check (pure report path per spec §3.6).
var skillsResult *selfupdate.NpmResult
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -185,16 +186,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
// skills_status: pure report, no side effect, no stamp write.
// ReadStamp errors are silently swallowed — if we can't read the
// stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
applySkillsStatus(out, cur)
output.PrintJson(io.Out, out)
return nil
}
@@ -210,7 +202,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 := runSkillsAndStamp(updater, io, cur, opts.Force)
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
reason := detect.ManualReason()
if opts.JSON {
@@ -288,10 +280,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
// Skills update (best-effort) — uses runSkillsAndStamp so the
// stamp gets persisted on success and dedup applies if a previous
// run already stamped this version.
skillsResult := runSkillsAndStamp(updater, io, latest, opts.Force)
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
@@ -328,27 +317,21 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
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))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
// stamp on success. Skips the npx invocation when the stamp already
// matches stampVersion (unless force is true). The stamp write failure
// emits a warning to io.ErrOut but does NOT fail the update command —
// best-effort. ReadStamp errors are swallowed (fail-closed: treated as
// out-of-sync, so npx re-runs). Returns nil iff skipped due to stamp
// dedup; otherwise returns the underlying *NpmResult with Err semantics
// from RunSkillsUpdate.
func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stampVersion string, force bool) *selfupdate.NpmResult {
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
if !force {
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
return nil
}
}
r := updater.RunSkillsUpdate()
if r.Err == nil {
if err := skillscheck.WriteStamp(stampVersion); err != nil {
fmt.Fprintf(io.ErrOut, "warning: skills synced but stamp not written: %v\n", err)
}
result := syncSkills(skillscheck.SyncOptions{
Version: stateVersion,
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)
}
return r
return result
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
@@ -356,7 +339,7 @@ func runSkillsAndStamp(updater *selfupdate.Updater, io *cmdutil.IOStreams, stamp
// fields derived from skillsResult. When check is true, this is the pure
// report path (spec §3.6): no side-effects, JSON envelope uses
// skills_status (spec §4.2) instead of skills_action.
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *skillscheck.SyncResult, check bool) error {
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
@@ -364,16 +347,7 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
}
if check {
// Pure report — read stamp directly, emit skills_status block.
// ReadStamp errors are silently swallowed — if we can't read
// the stamp we just omit the block rather than fail the --check.
if stamp, err := skillscheck.ReadStamp(); err == nil {
out["skills_status"] = map[string]interface{}{
"current": stamp,
"target": cur,
"in_sync": stamp == cur,
}
}
applySkillsStatus(out, cur)
} else {
applySkillsResult(out, skillsResult)
}
@@ -387,36 +361,70 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
return nil
}
// applySkillsResult mutates the JSON envelope to include skills_action
// (and skills_warning when failed). nil result = "in_sync" (dedup hit).
func applySkillsResult(env map[string]interface{}, r *selfupdate.NpmResult) {
func applySkillsStatus(env map[string]interface{}, target string) {
state, readable, err := skillscheck.ReadState()
if err != nil || !readable || state.Version == "" {
return
}
status := map[string]interface{}{
"current": state.Version,
"target": target,
"in_sync": normalizeVersion(state.Version) == normalizeVersion(target),
}
if len(state.OfficialSkills) > 0 {
status["official"] = len(state.OfficialSkills)
}
if len(state.UpdatedSkills) > 0 {
status["updated"] = len(state.UpdatedSkills)
}
if len(state.SkippedDeletedSkills) > 0 {
status["skipped_deleted"] = state.SkippedDeletedSkills
}
env["skills_status"] = status
}
func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
switch {
case r == nil:
env["skills_action"] = "in_sync"
case r.Err != nil:
env["skills_action"] = "failed"
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
env["skills_summary"] = skillsSummary(r)
default:
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
}
}
// emitSkillsTextHints prints human-readable feedback about the skills
// sync result for non-JSON output.
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
summary := map[string]interface{}{
"official": len(r.Official),
"updated": len(r.Updated),
"added": len(r.Added),
"skipped_deleted": len(r.SkippedDeleted),
}
if len(r.Failed) > 0 {
summary["failed"] = r.Failed
}
return summary
}
func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
switch {
case r == nil:
// dedup hit — silent (already up to date)
case r.Err != nil:
fmt.Fprintf(io.ErrOut, "%s Skills update failed: %v\n", symWarn(), r.Err)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
if len(r.Failed) > 0 {
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
}
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
case r.Force:
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
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))
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
}
}

View File

@@ -5,13 +5,14 @@ package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"os/exec"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -28,7 +29,6 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
@@ -41,22 +41,53 @@ func mockDetect(t *testing.T, result selfupdate.DetectResult) {
}
// mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once.
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
npmFn func(string) *selfupdate.NpmResult,
skillsFn func() *selfupdate.NpmResult) {
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.SkillsUpdateOverride = skillsFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:
}
return r
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: "1.2.3", want: "1.2.3"},
{input: "v1.2.3", want: "1.2.3"},
{input: "V1.2.3", want: "1.2.3"},
{input: " v1.2.3 ", want: "1.2.3"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := normalizeVersion(tt.input); got != tt.want {
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
f, stdout, _ := newTestFactory(t)
@@ -168,9 +199,7 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
// Isolate config dir because skills sync writes skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -186,7 +215,6 @@ func TestUpdateNpm_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -216,7 +244,6 @@ func TestUpdateNpm_Human(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -230,7 +257,7 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -246,7 +273,6 @@ func TestUpdateForce_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -323,7 +349,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -339,7 +365,6 @@ func TestUpdateDevVersion_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -451,8 +476,8 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
t.Fatal("skills update should not run when binary verification fails")
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
return u
@@ -649,7 +674,7 @@ func TestPermissionHint(t *testing.T) {
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
// Same state-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -668,7 +693,6 @@ func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -750,7 +774,6 @@ func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) {
mockDetectAndNpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true},
func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
err := cmd.Execute()
@@ -785,8 +808,7 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
// Skills update fails
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -812,8 +834,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
if !strings.Contains(out, "skills_warning") {
t.Errorf("expected skills_warning in output, got: %s", out)
}
if !strings.Contains(out, "skills_detail") {
t.Errorf("expected skills_detail in output, got: %s", out)
if !strings.Contains(out, "skills_summary") {
t.Errorf("expected skills_summary in output, got: %s", out)
}
}
@@ -838,7 +860,7 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -861,100 +883,96 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
if !strings.Contains(out, "Skills update failed") {
t.Errorf("expected skills failure warning, got: %s", out)
}
if !strings.Contains(out, "npx -y skills add") {
t.Errorf("expected manual skills command hint, got: %s", out)
if !strings.Contains(out, "lark-cli update --force") {
t.Errorf("expected force retry hint, got: %s", out)
}
}
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
// for direct calls to internals like runSkillsAndStamp that write to
// io.ErrOut.
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
func newTestIO() *cmdutil.IOStreams {
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
}
func TestRunSkillsAndStamp_DedupHit(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
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 {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got != nil {
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
}
if called {
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
t.Error("SkillsCommandOverride called, want skipped due to dedup")
}
}
func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
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 {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
if got == nil {
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
}
if !called {
t.Error("SkillsUpdateOverride not called with force=true")
t.Error("SkillsCommandOverride not called with force=true")
}
}
func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
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)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
}
func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
return r
},
}
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
}
}
@@ -973,8 +991,7 @@ func TestTruncate(t *testing.T) {
}
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -987,9 +1004,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1000,17 +1017,19 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
t.Error("skills sync not called in already-up-to-date branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
}
}
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -1029,9 +1048,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
ResolvedPath: "/usr/local/bin/lark-cli",
}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1042,17 +1061,19 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in manual branch, want called")
t.Error("skills sync not called in manual branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.21" {
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
}
}
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
origFetch := fetchLatest
origCur := currentVersion
@@ -1075,9 +1096,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
return &selfupdate.NpmResult{}
},
VerifyOverride: func(expectedVersion string) error { return nil },
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1088,18 +1109,25 @@ func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("RunSkillsUpdate not called in npm branch")
t.Error("skills sync not called in npm branch")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.22" {
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.22" {
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
}
}
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{
Version: "1.0.20",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedSkills: []string{"lark-calendar"},
SkippedDeletedSkills: []string{"lark-mail"},
}); err != nil {
t.Fatal(err)
}
@@ -1117,9 +1145,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1130,7 +1158,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
t.Fatalf("updateRun(--check) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
t.Error("skills sync called under --check, want skipped")
}
var env map[string]interface{}
@@ -1144,12 +1172,14 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false {
t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status)
}
if status["official"] != float64(2) || status["updated"] != float64(1) {
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
}
}
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
@@ -1164,9 +1194,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return &selfupdate.NpmResult{}
return successfulSkillsCommand()(args...)
},
}
}
@@ -1177,12 +1207,15 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
}
if skillsCalled {
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
t.Error("skills sync called under --check (already-latest), want skipped")
}
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp)
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
}
var env map[string]interface{}
@@ -1204,39 +1237,248 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
}
}
// TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning
// emission when RunSkillsUpdate succeeds but WriteStamp fails.
func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) {
// Force WriteStamp to fail by pointing config dir at a path that exists
// as a regular file (so MkdirAll fails).
tmp := t.TempDir()
badPath := filepath.Join(tmp, "blocker")
if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
origSync := syncSkills
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
t.Cleanup(func() { syncSkills = origSync })
f, _, stderr := newTestFactory(t)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{} // success
},
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
}
got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
}
if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") {
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
t.Errorf("stderr does not contain warning: %q", stderr.String())
}
}
// TestEmitSkillsTextHints_Success verifies the "Skills updated" success
// message is printed to ErrOut on a successful (Err == nil) result.
func TestEmitSkillsTextHints_Success(t *testing.T) {
f, _, stderr := newTestFactory(t)
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
if !strings.Contains(stderr.String(), "Skills updated") {
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
}
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
// intentionally removed them while they were still official skills.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
before := skillscheck.SkillsState{
Version: "1.0.19",
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
AddedOfficialSkills: []string{},
SkippedDeletedSkills: []string{},
UpdatedAt: "2026-05-20T00:00:00Z",
}
if err := skillscheck.WriteState(before); err != nil {
t.Fatal(err)
}
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.19" {
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
}
// Phase 3: Mock version functions so the update command believes it has
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
// This triggers SyncSkills which calls the real npx skills add command.
origFetch := fetchLatest
origVersion := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
fetchLatest = func() (string, error) { return "1.0.20", nil }
currentVersion = func() string { return "1.0.20" }
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("lark-cli update --json err = %v, want nil", err)
}
// Phase 4: Verify the state file was rewritten with the new version,
// non-empty official/updated skill lists, and a refreshed timestamp.
state, readable, err = skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
}
if len(state.OfficialSkills) == 0 {
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
}
if len(state.UpdatedSkills) == 0 {
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
}
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
}
// Verify that previously-skipped skills are handled correctly:
// - If locally installed → should appear in UpdatedSkills (updated to latest)
// - If locally absent → should NOT be force-restored in UpdatedSkills,
// and should remain in SkippedDeletedSkills
for _, skill := range []string{"lark-doc", "lark-mail"} {
if containsString(localSkills, skill) {
if !containsString(state.UpdatedSkills, skill) {
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
}
continue
}
if containsString(state.UpdatedSkills, skill) {
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
}
if !containsString(state.SkippedDeletedSkills, skill) {
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
}
}
// Phase 5: Verify the JSON output structure is parseable and contains
// the expected action fields for AI agent consumption.
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
}
if env["action"] != "already_up_to_date" {
t.Errorf("action = %v, want already_up_to_date", env["action"])
}
if env["skills_action"] != "synced" {
t.Errorf("skills_action = %v, want synced", env["skills_action"])
}
}
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if _, readable, _ := skillscheck.ReadState(); readable {
t.Fatal("skills-state.json should not exist before update")
}
// Phase 3: Mock version functions so the update command believes it is at
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
// which calls the real npx skills add command.
origFetch := fetchLatest
origVersion := currentVersion
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
fetchLatest = func() (string, error) { return "1.0.20", nil }
currentVersion = func() string { return "1.0.20" }
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("lark-cli update --json err = %v, want nil", err)
}
// Phase 4: Verify the state file was created with all official skills in
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
state, readable, err := skillscheck.ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.20" {
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
}
if len(state.OfficialSkills) == 0 {
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
}
if len(state.UpdatedSkills) == 0 {
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
}
if state.UpdatedAt == "" {
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
}
// All locally installed official skills must appear in UpdatedSkills.
officialSet := map[string]bool{}
for _, s := range state.OfficialSkills {
officialSet[s] = true
}
for _, skill := range localSkills {
if !officialSet[skill] {
continue
}
if !containsString(state.UpdatedSkills, skill) {
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
}
}
// No skill should be in SkippedDeletedSkills on cold start — there is no
// previous state recording a user deletion to preserve.
if len(state.SkippedDeletedSkills) != 0 {
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
}
// Phase 5: Verify the JSON output structure is parseable and contains
// the expected action fields for AI agent consumption.
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String())
}
if env["action"] != "already_up_to_date" {
t.Errorf("action = %v, want already_up_to_date", env["action"])
}
if env["skills_action"] != "synced" {
t.Errorf("skills_action = %v, want synced", env["skills_action"])
}
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -41,6 +41,7 @@ func wrapError(op string, err error) error {
if errors.Is(err, errNotInitialized) {
hint = "The keychain master key may have been cleaned up or deleted. If running inside a sandbox or CI environment, please ensure the process has the necessary permissions to access the keychain, you can try running this outside the sandbox. Otherwise, please reconfigure the CLI by running lark-cli config init."
}
hint += extraHint(err)
func() {
defer func() { recover() }()

View File

@@ -43,6 +43,12 @@ var keyringGet = keyring.Get
// keyringSet is overridden in tests to simulate system keychain writes.
var keyringSet = keyring.Set
// errKeychainBlocked is returned when the OS Keychain is reachable but
// denies access — sandbox restriction, user-denied prompt, or a 5-second
// timeout (typically caused by an ignored permission dialog). Distinct
// from errNotInitialized (master key entry genuinely absent).
var errKeychainBlocked = errors.New("keychain access blocked")
// StorageDir returns the storage directory for a given service name on macOS.
func StorageDir(service string) string {
home, err := vfs.UserHomeDir()
@@ -85,7 +91,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
return
} else if !errors.Is(err, keyring.ErrNotFound) {
// Not ErrNotFound, which means access was denied or blocked by the system
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
resCh <- result{key: nil, err: errKeychainBlocked}
return
}
@@ -117,7 +123,7 @@ func getMasterKey(service string, allowCreate bool) ([]byte, error) {
return res.key, res.err
case <-ctx.Done():
// Timeout is usually caused by ignored/blocked permission prompts
return nil, errors.New("keychain access blocked")
return nil, errKeychainBlocked
}
}
@@ -265,11 +271,7 @@ func platformGet(service, account string) (string, error) {
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
}
return plaintext, nil
return decryptData(data, key)
}
// platformSet stores a value in the macOS keychain.
@@ -316,3 +318,116 @@ func platformRemove(service, account string) error {
}
return nil
}
// DowngradeResult reports what DowngradeMasterKeyToFile did. The command
// never writes to or removes from the OS Keychain — it only reads from it
// and only writes to the local file fallback.
type DowngradeResult int
const (
// DowngradeAlreadyDone means master.key.file was already present and valid.
DowngradeAlreadyDone DowngradeResult = iota
// DowngradeUsedKeychainKey means the existing OS Keychain master key was
// copied verbatim into the local file fallback. Existing .enc credentials
// remain readable via the file path.
DowngradeUsedKeychainKey
// DowngradeCreatedNewKey means the OS Keychain held no master key, so a
// fresh random key was generated and written to the file fallback only.
// The OS Keychain was not touched.
DowngradeCreatedNewKey
)
// MasterKeyFilePath returns the absolute path of the file fallback master key
// for the given service.
func MasterKeyFilePath(service string) string {
return filepath.Join(StorageDir(service), fileMasterKeyName)
}
// DowngradeMasterKeyToFile materializes the OS Keychain master key into the
// local file fallback so that subsequent platformGet calls take the file-first
// path and bypass the OS Keychain entirely. The Keychain entry itself is kept
// as a cold backup; nothing is removed there.
//
// Idempotent: if master.key.file is already present and valid, returns
// DowngradeAlreadyDone without touching anything.
func DowngradeMasterKeyToFile(service string) (DowngradeResult, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, fileMasterKeyName)
existing, statErr := vfs.ReadFile(keyPath)
if statErr == nil {
if len(existing) == masterKeyBytes {
return DowngradeAlreadyDone, nil
}
return 0, errors.New("keychain is corrupted")
}
if !errors.Is(statErr, os.ErrNotExist) {
return 0, statErr
}
result := DowngradeUsedKeychainKey
key, err := getMasterKey(service, false)
if err != nil {
if !errors.Is(err, errNotInitialized) {
return 0, err
}
// Keychain has no master key. Generate a fresh one *locally* — do
// NOT call getMasterKey(service, true), which would write the new
// key into the OS Keychain as a side effect. keychain-downgrade
// must never modify the OS Keychain; it only ever reads from it.
key = make([]byte, masterKeyBytes)
if _, err := rand.Read(key); err != nil {
return 0, err
}
result = DowngradeCreatedNewKey
}
if err := vfs.MkdirAll(dir, 0700); err != nil {
return 0, err
}
file, err := vfs.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
if errors.Is(err, os.ErrExist) {
concurrent, readErr := vfs.ReadFile(keyPath)
if readErr == nil && len(concurrent) == masterKeyBytes {
return DowngradeAlreadyDone, nil
}
if readErr != nil {
return 0, readErr
}
return 0, errors.New("keychain is corrupted")
}
return 0, err
}
writeFailed := true
defer func() {
if writeFailed {
_ = vfs.Remove(keyPath)
}
}()
if _, err := file.Write(key); err != nil {
_ = file.Close()
return 0, err
}
if err := file.Close(); err != nil {
return 0, err
}
writeFailed = false
return result, nil
}
// extraHint appends a darwin-specific suggestion to wrapError's hint message
// when the failure is one keychain-downgrade can recover from: either the
// master key is missing (errNotInitialized) or the OS Keychain is reachable
// but blocking access (errKeychainBlocked — sandbox, denied prompt, timeout).
// In both cases the user can run keychain-downgrade from an interactive
// Terminal session, after which the file fallback is readable from any
// context (sandbox, automation, CI, etc.). Corruption errors are
// deliberately excluded — downgrade would re-read the same bad bytes and
// fail; the right fix there is to delete the corrupt Keychain entry first.
func extraHint(err error) string {
if errors.Is(err, errNotInitialized) || errors.Is(err, errKeychainBlocked) {
return " On macOS, you can also open an interactive Terminal session (where the system Keychain is reachable) and run `lark-cli config keychain-downgrade` to materialize the master key into a local file; subsequent runs in this sandbox/automation context will then read from the file and succeed. Trade-off: after downgrade, any process running as your macOS user can read that file (file permissions replace the Keychain's per-app ACL)."
}
return ""
}

View File

@@ -10,8 +10,10 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -111,6 +113,305 @@ func TestPlatformGetPrefersFileMasterKey(t *testing.T) {
}
}
// TestDowngradeAlreadyDoneIsIdempotent verifies that re-running downgrade
// when master.key.file already exists is a no-op and reports AlreadyDone
// without touching the system keychain.
func TestDowngradeAlreadyDoneIsIdempotent(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
t.Fatalf("keyringGet should not be called when master.key.file is already valid")
return "", nil
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet should not be called when master.key.file is already valid")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
preExisting := make([]byte, masterKeyBytes)
for i := range preExisting {
preExisting[i] = byte(i + 7)
}
keyPath := filepath.Join(dir, fileMasterKeyName)
if err := os.WriteFile(keyPath, preExisting, 0600); err != nil {
t.Fatalf("WriteFile(master key) error = %v", err)
}
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeAlreadyDone {
t.Fatalf("result = %v, want DowngradeAlreadyDone", result)
}
after, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytesEqual(after, preExisting) {
t.Fatalf("master.key.file content changed; want preserved")
}
}
// TestDowngradeCopiesKeychainKeyToFile verifies the happy path: a keychain
// key exists, the file does not, and downgrade copies the bytes verbatim
// so that existing .enc files (encrypted with the keychain key) remain
// readable via the file fallback.
func TestDowngradeCopiesKeychainKeyToFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
keychainKey := make([]byte, masterKeyBytes)
for i := range keychainKey {
keychainKey[i] = byte(i + 11)
}
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return base64.StdEncoding.EncodeToString(keychainKey), nil
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet should not be called when keychain already has a master key")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeUsedKeychainKey {
t.Fatalf("result = %v, want DowngradeUsedKeychainKey", result)
}
got, err := os.ReadFile(MasterKeyFilePath(service))
if err != nil {
t.Fatalf("ReadFile(master.key.file) error = %v", err)
}
if !bytesEqual(got, keychainKey) {
t.Fatalf("file key bytes do not match keychain key; existing .enc files would become unreadable")
}
}
// TestDowngradeCreatesNewKeyWhenStorageEmpty verifies the "fresh user"
// path: keychain is empty and no .enc files exist, so we generate a new
// random key and write it to the file fallback. The OS Keychain is NOT
// modified (regression guard for the side-effecting getMasterKey(_, true)
// call we used to make).
func TestDowngradeCreatesNewKeyWhenStorageEmpty(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return "", keyring.ErrNotFound
}
keyringSet = func(service, user, password string) error {
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeCreatedNewKey {
t.Fatalf("result = %v, want DowngradeCreatedNewKey", result)
}
fileKey, err := os.ReadFile(MasterKeyFilePath(service))
if err != nil {
t.Fatalf("ReadFile(master.key.file) error = %v", err)
}
if len(fileKey) != masterKeyBytes {
t.Fatalf("file key length = %d, want %d", len(fileKey), masterKeyBytes)
}
}
// TestDowngradeDoesNotClobberConcurrentlyWrittenKey is the regression guard
// for the TOCTOU between the initial existence check and the final write.
// Race trace the fix closes:
//
// T0 proc A: ReadFile(keyPath) → ErrNotExist (initial check passes)
// T1 proc B: platformSet → getFileMasterKey(_, true) creates keyPath with K_B
// then writes .enc encrypted with K_B
// T2 proc A: rand.Read → K_A; would overwrite K_B and orphan B's .enc
//
// We simulate proc B's interleaving by performing the concurrent file write
// inside the keyringGet hook — by the time DowngradeMasterKeyToFile gets back
// to the final OpenFile call, the file already exists, the O_EXCL branch
// fires, and the concurrent key is preserved verbatim.
func TestDowngradeDoesNotClobberConcurrentlyWrittenKey(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
service := "test-service"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
concurrentKey := make([]byte, masterKeyBytes)
for i := range concurrentKey {
concurrentKey[i] = byte(i + 77)
}
origGet := keyringGet
origSet := keyringSet
keyringGet = func(svc, user string) (string, error) {
if err := os.WriteFile(filepath.Join(dir, fileMasterKeyName), concurrentKey, 0600); err != nil {
t.Fatalf("simulated concurrent write failed: %v", err)
}
return "", keyring.ErrNotFound
}
keyringSet = func(svc, user, password string) error {
t.Fatalf("keyringSet must not be called; keychain-downgrade never writes to the system Keychain")
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
result, err := DowngradeMasterKeyToFile(service)
if err != nil {
t.Fatalf("DowngradeMasterKeyToFile() error = %v", err)
}
if result != DowngradeAlreadyDone {
t.Fatalf("result = %v, want DowngradeAlreadyDone (concurrent write must be preserved)", result)
}
got, err := os.ReadFile(filepath.Join(dir, fileMasterKeyName))
if err != nil {
t.Fatalf("ReadFile error = %v", err)
}
if !bytesEqual(got, concurrentKey) {
t.Fatalf("master.key.file was clobbered; concurrent platformSet's encrypted credentials would be orphaned")
}
}
// TestPlatformGetSurfacesKeychainBlocked verifies that "keychain access blocked"
// (the sandbox case) propagates as errKeychainBlocked through platformGet, so
// the wrapError hint chain can attach the keychain-downgrade suggestion.
func TestPlatformGetSurfacesKeychainBlocked(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
origGet := keyringGet
origSet := keyringSet
keyringGet = func(service, user string) (string, error) {
return "", errors.New("sandbox denied keychain access")
}
keyringSet = func(service, user, password string) error {
return nil
}
t.Cleanup(func() {
keyringGet = origGet
keyringSet = origSet
})
service := "test-service"
account := "test-account"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
lostKey := make([]byte, masterKeyBytes)
for i := range lostKey {
lostKey[i] = byte(i + 55)
}
encrypted, err := encryptData("secret", lostKey)
if err != nil {
t.Fatalf("encryptData() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), encrypted, 0600); err != nil {
t.Fatalf("WriteFile(.enc) error = %v", err)
}
_, err = platformGet(service, account)
if !errors.Is(err, errKeychainBlocked) {
t.Fatalf("err = %v, want errKeychainBlocked", err)
}
}
// TestWrapErrorHintMentionsDowngradeForRecoverableCases is the regression
// guard for the bug where `lark-cli api ...` inside a sandbox surfaced
// "keychain access blocked" but the hint did NOT mention keychain-downgrade
// — the very command meant to recover from that exact situation. Root cause:
// the blocked path used an anonymous errors.New string, so the extraHint
// `errors.Is` check (only matched errNotInitialized) couldn't recognize it.
//
// Asserts the full wrapError → ExitError.Detail.Hint pipeline:
// - errKeychainBlocked + errNotInitialized → hint mentions keychain-downgrade
// - "keychain is corrupted" (downgrade would re-read the same bad bytes) → no mention
// - generic errors → no mention
//
// Add new cases here whenever extraHint's matcher widens, to keep the
// promise that the hint is suggested iff downgrade can actually help.
func TestWrapErrorHintMentionsDowngradeForRecoverableCases(t *testing.T) {
cases := []struct {
name string
err error
wantHint bool
}{
{"access blocked (sandbox / denied prompt / timeout)", errKeychainBlocked, true},
{"not initialized (missing master key)", errNotInitialized, true},
{"corrupted (downgrade would re-read the same bad bytes)", errors.New("keychain is corrupted"), false},
{"unrelated generic error", errors.New("something else entirely"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := wrapError("Get", tc.err)
var ee *output.ExitError
if !errors.As(err, &ee) || ee.Detail == nil {
t.Fatalf("wrapError returned %#v; expected *output.ExitError with Detail", err)
}
got := strings.Contains(ee.Detail.Hint, "keychain-downgrade")
if got != tc.wantHint {
t.Fatalf("hint mentions keychain-downgrade = %v, want %v\n full hint: %q", got, tc.wantHint, ee.Detail.Hint)
}
})
}
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestPlatformSetPrefersExistingFileMasterKey verifies writes stay on the file-based
// master key path once the fallback master key already exists.
func TestPlatformSetPrefersExistingFileMasterKey(t *testing.T) {

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !darwin
package keychain
// extraHint is a no-op on non-darwin platforms. The keychain-downgrade
// command is macOS-only, so there is no extra suggestion to surface.
func extraHint(err error) string { return "" }

View File

@@ -78,12 +78,12 @@ func (r *NpmResult) CombinedOutput() string {
// Platform-specific methods (PrepareSelfReplace, CleanupStaleFiles)
// are in updater_unix.go and updater_windows.go.
//
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsUpdateOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -153,12 +153,27 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunSkillsUpdate installs skills, trying the .well-known source first and
// falling back to the GitHub repo on failure or timeout.
func (u *Updater) RunSkillsUpdate() *NpmResult {
if u.SkillsUpdateOverride != nil {
return u.SkillsUpdateOverride()
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
return r
}
func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
return r
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
@@ -167,6 +182,28 @@ func (u *Updater) RunSkillsUpdate() *NpmResult {
}
func (u *Updater) runSkillsAdd(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
}
func (u *Updater) runSkillsListOfficial(source string) *NpmResult {
return u.runSkillsCommand("-y", "skills", "add", source, "--list")
}
func (u *Updater) runSkillsListGlobal() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g")
}
func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult {
args := []string{"-y", "skills", "add", source, "-s"}
args = append(args, nameList...)
args = append(args, "-g", "-y")
return u.runSkillsCommand(args...)
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
npxPath, err := exec.LookPath("npx")
if err != nil {
@@ -175,7 +212,7 @@ func (u *Updater) runSkillsAdd(source string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
@@ -166,3 +167,87 @@ func TestVerifyBinaryEmptyOutput(t *testing.T) {
t.Fatal("VerifyBinary(empty output) expected error, got nil")
}
}
func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
tests := []struct {
name string
run func(*Updater) *NpmResult
want string
}{
{
name: "list official primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsListOfficial("https://open.feishu.cn")
},
want: "-y skills add https://open.feishu.cn --list",
},
{
name: "list global",
run: func(u *Updater) *NpmResult {
return u.runSkillsListGlobal()
},
want: "-y skills ls -g",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
return u.runSkillsInstall("https://open.feishu.cn", []string{"lark-mail"})
},
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell script")
}
dir := t.TempDir()
script := filepath.Join(dir, "npx")
logPath := filepath.Join(dir, "npx.log")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \""+logPath+"\"\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
result := tt.run(New())
if result.Err != nil {
t.Fatalf("command err = %v, want nil", result.Err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(raw)) != tt.want {
t.Fatalf("args = %q, want %q", strings.TrimSpace(string(raw)), tt.want)
}
})
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{
SkillsCommandOverride: func(args ...string) *NpmResult {
called = append(called, strings.Join(args, " "))
r := &NpmResult{}
if strings.Contains(strings.Join(args, " "), "https://open.feishu.cn") {
r.Err = fmt.Errorf("primary failed")
return r
}
r.Stdout.WriteString("lark-calendar\n")
return r
},
}
result := updater.ListOfficialSkills()
if result.Err != nil {
t.Fatalf("ListOfficialSkills() err = %v, want nil", result.Err)
}
if len(called) != 2 {
t.Fatalf("called %d commands, want 2: %#v", len(called), called)
}
if !strings.Contains(called[1], "larksuite/cli --list") {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}

View File

@@ -3,46 +3,29 @@
package skillscheck
// Init runs the synchronous skills version check. Stores a StaleNotice
// when the local stamp records a version that does not match
// currentVersion. Safe to call from cmd/root.go before rootCmd.Execute();
// zero network, zero subprocess — only a local stamp file read.
import "strings"
// Init runs the synchronous skills version check. Stores a StaleNotice when
// the local skills state records a version that does not match currentVersion.
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local state file read.
//
// Skip rules: see shouldSkip (CI envs, DEV builds, non-release semver,
// LARKSUITE_CLI_NO_SKILLS_NOTIFIER opt-out).
//
// Failure modes (all → no notice, no nag):
// - shouldSkip rule met
// - ReadStamp returns an I/O error other than ENOENT
// - Stamp matches currentVersion (in-sync)
// - Stamp is missing (cold start) — only users who ran `lark-cli update`
// opt into drift tracking; npx-only installs are intentionally silent.
func Init(currentVersion string) {
// Clear any stale notice from a prior call so early returns below
// (skip rules / read errors / cold start / in-sync) leave pending == nil
// instead of preserving a stale value from a previous Init invocation.
SetPending(nil)
if shouldSkip(currentVersion) {
return
}
stamp, err := ReadStamp()
if err != nil {
// Fail closed — don't nag for a transient FS problem.
version, ok := ReadSyncedVersion()
if !ok {
return
}
if stamp == "" {
// Cold start: the stamp is written exclusively by `lark-cli update`
// (runSkillsAndStamp). Users who installed skills via
// `npx skills add larksuite/cli -g` have no stamp yet — they must
// not be nagged with "skills not installed", since the on-disk
// skills directory may already be fully populated.
return
}
if stamp == currentVersion {
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
return
}
SetPending(&StaleNotice{
Current: stamp, // guaranteed non-empty under the new contract
Current: version,
Target: currentVersion,
})
}

View File

@@ -18,9 +18,8 @@ func resetPending(t *testing.T) {
func TestInit_InSync_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -39,12 +38,24 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
}
}
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
t.Fatal(err)
}
Init("v1.0.21")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (normalized versions are in-sync)", got)
}
}
func TestInit_Drift_NoticeWithStateVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.20"}); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -61,22 +72,18 @@ func TestInit_Skipped_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
// Even with an empty config dir (no stamp), DEV version should skip
// the check entirely and never emit a notice.
Init("DEV")
if got := GetPending(); got != nil {
t.Errorf("GetPending() = %+v, want nil (skip rules met)", got)
}
}
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
func TestInit_ReadStateError_FailsClosed(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Make the stamp path a directory so vfs.ReadFile returns a
// non-ENOENT I/O error.
if err := os.MkdirAll(filepath.Join(dir, "skills.stamp"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
t.Fatal(err)
}
Init("1.0.21")

View File

@@ -3,9 +3,8 @@
// Package skillscheck verifies that the locally installed lark-cli
// skills are in sync with the running binary version, by comparing
// the current binary version against a stamp file written when skills
// are last synced (by `lark-cli update`). On mismatch it stores a
// notice for injection into JSON envelopes via output.PendingNotice.
// the current binary version against skills-state.json. On mismatch it
// stores a notice for injection into JSON envelopes via output.PendingNotice.
package skillscheck
import (
@@ -26,8 +25,7 @@ type StaleNotice struct {
// Message returns a single-line, AI-agent-parseable description of the
// drift plus the canonical fix command. Mirrors internal/update.UpdateInfo.Message
// in style ("..., run: lark-cli update" suffix). Current is guaranteed
// non-empty because Init only emits a StaleNotice for the drift case
// (stamp present and != binary version).
// non-empty because Init only emits a StaleNotice for the drift case.
func (s *StaleNotice) Message() string {
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"errors"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const stampFile = "skills.stamp"
// stampPath returns ~/.lark-cli/skills.stamp.
// Uses the BASE config dir (not workspace-aware) because skills install
// globally via `npx -g`; per-workspace tracking would produce false
// drift signals when switching workspaces.
func stampPath() string {
return filepath.Join(core.GetBaseConfigDir(), stampFile)
}
// ReadStamp returns the version recorded in the stamp file. Returns
// ("", nil) when the file does not exist (interpreted as "never synced").
// Other I/O errors are returned as-is so callers can fail closed.
func ReadStamp() (string, error) {
data, err := vfs.ReadFile(stampPath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
return strings.TrimSpace(string(data)), nil
}
// WriteStamp records `version` as the last successfully synced skills
// version. Atomic via tmp + rename (validate.AtomicWrite). Creates
// the base config directory if it does not exist.
func WriteStamp(version string) error {
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
return validate.AtomicWrite(stampPath(), []byte(version), 0o644)
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"os"
"path/filepath"
"testing"
)
func TestReadStamp_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := ReadStamp()
if err != nil {
t.Fatalf("ReadStamp() err = %v, want nil for ENOENT", err)
}
if got != "" {
t.Errorf("ReadStamp() = %q, want \"\" for missing file", got)
}
}
func TestReadStamp_Normal(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "1.0.21" {
t.Errorf("ReadStamp() = (%q, %v), want (\"1.0.21\", nil)", got, err)
}
}
func TestReadStamp_TrailingNewlineTolerated(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte("1.0.21\n"), 0o644); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() = %q, want \"1.0.21\" (newline trimmed)", got)
}
}
func TestReadStamp_EmptyFile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "skills.stamp"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadStamp()
if err != nil || got != "" {
t.Errorf("ReadStamp() = (%q, %v), want (\"\", nil)", got, err)
}
}
func TestWriteStamp_CreatesDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatalf("WriteStamp() = %v, want nil", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(got) != "1.0.21" {
t.Errorf("file content = %q, want \"1.0.21\"", string(got))
}
}
func TestWriteStamp_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
got, _ := ReadStamp()
if got != "1.0.21" {
t.Errorf("ReadStamp() after overwrite = %q, want \"1.0.21\"", got)
}
}
func TestWriteStamp_NoTrailingNewline(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "skills.stamp"))
if string(raw) != "1.0.21" {
t.Errorf("raw file = %q, want exactly \"1.0.21\" (no newline)", string(raw))
}
}
// TestWriteStamp_MkdirAllFailure verifies WriteStamp returns the mkdir error
// when the base config dir cannot be created (parent path is a regular file).
func TestWriteStamp_MkdirAllFailure(t *testing.T) {
tmp := t.TempDir()
blocker := filepath.Join(tmp, "blocker")
// Create a regular file where MkdirAll wants to create a directory.
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err)
}
// Point the config dir at a path UNDER the regular file — MkdirAll must fail.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(blocker, "child"))
if err := WriteStamp("1.0.21"); err == nil {
t.Fatal("WriteStamp() = nil, want non-nil error from MkdirAll failure")
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
const (
stateFile = "skills-state.json"
)
var ErrUnreadableState = errors.New("skills state is unreadable")
type SkillsState struct {
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
AddedOfficialSkills []string `json:"added_official_skills"`
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
UpdatedAt string `json:"updated_at"`
}
func statePath() string {
return filepath.Join(core.GetBaseConfigDir(), stateFile)
}
func ReadState() (*SkillsState, bool, error) {
data, err := vfs.ReadFile(statePath())
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
return nil, false, err
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
}
var state SkillsState
if err := json.Unmarshal(data, &state); err != nil {
return nil, false, fmt.Errorf("%w: %v", ErrUnreadableState, err)
}
return &state, true, nil
}
func WriteState(state SkillsState) error {
state.ensureNonNilSlices()
if err := vfs.MkdirAll(core.GetBaseConfigDir(), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(statePath(), append(data, '\n'), 0o644)
}
func ReadSyncedVersion() (string, bool) {
state, ok, err := ReadState()
if err != nil || !ok || state.Version == "" {
return "", false
}
return state.Version, true
}
func (s *SkillsState) ensureNonNilSlices() {
if s.OfficialSkills == nil {
s.OfficialSkills = []string{}
}
if s.UpdatedSkills == nil {
s.UpdatedSkills = []string{}
}
if s.AddedOfficialSkills == nil {
s.AddedOfficialSkills = []string{}
}
if s.SkippedDeletedSkills == nil {
s.SkippedDeletedSkills = []string{}
}
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestReadState_Missing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
state, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil for missing file", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false for missing file")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil for missing file", state)
}
}
func TestReadState_Valid(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
want := SkillsState{
Version: "1.2.3",
OfficialSkills: []string{"lark-doc", "lark-im"},
UpdatedSkills: []string{"lark-doc"},
AddedOfficialSkills: []string{"lark-task"},
SkippedDeletedSkills: []string{"custom-skill"},
UpdatedAt: "2026-05-18T10:00:00Z",
}
data, err := json.Marshal(want)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, stateFile), data, 0o644); err != nil {
t.Fatal(err)
}
got, ok, err := ReadState()
if err != nil {
t.Fatalf("ReadState() err = %v, want nil", err)
}
if !ok {
t.Fatal("ReadState() ok = false, want true")
}
if got == nil {
t.Fatal("ReadState() state = nil, want state")
}
if !reflect.DeepEqual(*got, want) {
t.Fatalf("ReadState() state = %#v, want %#v", *got, want)
}
}
func TestReadState_CorruptStateUnreadable(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, stateFile), []byte(`{"version":`), 0o644); err != nil {
t.Fatal(err)
}
state, ok, err := ReadState()
if !errors.Is(err, ErrUnreadableState) {
t.Fatalf("ReadState() err = %v, want ErrUnreadableState", err)
}
if ok {
t.Fatal("ReadState() ok = true, want false")
}
if state != nil {
t.Fatalf("ReadState() state = %#v, want nil", state)
}
}
func TestWriteState_CreatesDirAndWritesState(t *testing.T) {
dir := filepath.Join(t.TempDir(), "nested")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
state := SkillsState{
Version: "1.2.3",
UpdatedAt: "2026-05-18T10:00:00Z",
}
if err := WriteState(state); err != nil {
t.Fatalf("WriteState() err = %v, want nil", err)
}
raw, err := os.ReadFile(filepath.Join(dir, stateFile))
if err != nil {
t.Fatal(err)
}
var got SkillsState
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("written state is invalid JSON: %v", err)
}
if got.Version != state.Version {
t.Fatalf("version = %q, want %q", got.Version, state.Version)
}
if got.OfficialSkills == nil {
t.Fatal("official_skills decoded as nil, want empty slice")
}
if got.UpdatedSkills == nil {
t.Fatal("updated_skills decoded as nil, want empty slice")
}
if got.AddedOfficialSkills == nil {
t.Fatal("added_skills decoded as nil, want empty slice")
}
if got.SkippedDeletedSkills == nil {
t.Fatal("skipped_deleted_skills decoded as nil, want empty slice")
}
}
func TestReadSyncedVersionFromState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for missing state", got, ok)
}
if err := WriteState(SkillsState{Version: "1.2.3"}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); !ok || got != "1.2.3" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"1.2.3\", true)", got, ok)
}
if err := WriteState(SkillsState{}); err != nil {
t.Fatal(err)
}
if got, ok := ReadSyncedVersion(); ok || got != "" {
t.Fatalf("ReadSyncedVersion() = (%q, %v), want (\"\", false) for empty version", got, ok)
}
}

View File

@@ -0,0 +1,399 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
var (
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
)
type SyncInput struct {
Version string
OfficialSkills []string
LocalSkills []string
PreviousState *SkillsState
StateReadable bool
Force bool
}
type SyncPlan struct {
Version string
OfficialSkills []string
ToUpdate []string
Added []string
SkippedDeleted []string
}
func stripANSI(s string) string {
return ansiPattern.ReplaceAllString(s, "")
}
func ParseSkillsList(text string) []string {
text = stripANSI(text)
lines := strings.Split(text, "\n")
// Detect format type
hasGlobalSkills := strings.Contains(text, "Global Skills")
hasAvailableSkills := strings.Contains(text, "Available Skills")
if hasGlobalSkills {
// Format 1: locally installed skills list from "npx -y skills ls -g"
return parseGlobalSkillsList(lines)
} else if hasAvailableSkills {
// Format 2: official skills list from "npx -y skills add ... --list"
return parseOfficialSkillsList(lines)
}
return nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip header
if strings.HasPrefix(trimmed, "Global Skills") {
continue
}
// Skip empty lines
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "Tip:") {
continue
}
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}
// Extract skill name, format is typically "skill-name /path/to/skill"
parts := strings.Fields(trimmed)
if len(parts) == 0 {
continue
}
candidate := parts[0]
// Validate and add
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
inAvailableSection := false
for _, line := range lines {
// Check if we've reached the "Available Skills" section
if strings.Contains(line, "Available Skills") {
inAvailableSection = true
continue
}
if !inAvailableSection {
continue
}
// Process lines containing "│", e.g. " │ lark-approval "
if strings.Contains(line, "│") {
// Remove all "│" characters and spaces, extract the first valid token in order
parts := strings.FieldsFunc(line, func(r rune) bool {
return r == '│' || r == ' '
})
if len(parts) > 0 {
candidate := parts[0]
// Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
}
}
return sortedKeys(seen)
}
func PlanSync(input SyncInput) SyncPlan {
official := uniqueSorted(input.OfficialSkills)
if input.Force {
return SyncPlan{
Version: input.Version,
OfficialSkills: official,
ToUpdate: official,
Added: []string{},
SkippedDeleted: []string{},
}
}
officialSet := toSet(official)
installedOfficial := intersection(input.LocalSkills, officialSet)
previousOfficial := []string{}
if input.StateReadable && input.PreviousState != nil {
previousOfficial = input.PreviousState.OfficialSkills
}
previousSet := toSet(previousOfficial)
newAddedOfficial := []string{}
for _, skill := range official {
if !previousSet[skill] {
newAddedOfficial = append(newAddedOfficial, skill)
}
}
updateSet := toSet(installedOfficial)
for _, skill := range newAddedOfficial {
updateSet[skill] = true
}
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,
}
}
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
}
type SyncOptions struct {
Version string
Force bool
Runner SkillsRunner
Now func() time.Time
}
type SyncResult struct {
Action string
Official []string
Updated []string
Added []string
SkippedDeleted []string
Failed []string
Err error
Detail string
Force bool
}
func SyncSkills(opts SyncOptions) *SyncResult {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Runner == nil {
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
}
// --- Step 1: List official skills ---
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
}
// --- Step 2: List local (installed) skills ---
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
}
// --- Step 3: Read previous state ---
previous, readable, err := ReadState()
if err != nil {
readable = false
previous = nil
}
plan := PlanSync(SyncInput{
Version: opts.Version,
OfficialSkills: official,
LocalSkills: local,
PreviousState: previous,
StateReadable: readable,
Force: opts.Force,
})
result := &SyncResult{
Action: "synced",
Official: plan.OfficialSkills,
Updated: plan.ToUpdate,
Added: plan.Added,
SkippedDeleted: plan.SkippedDeleted,
Force: opts.Force,
}
if len(plan.ToUpdate) > 0 {
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
if installResult == nil || installResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(installResult), official)
}
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: plan.OfficialSkills,
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
result.Err = fmt.Errorf("skills synced but state not written: %w", err)
return result
}
return result
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state
// records the full official list; otherwise a minimal state (version only) is
// written to break the fallback loop.
func fallbackFullInstall(opts SyncOptions, reason string, official []string) *SyncResult {
installResult := opts.Runner.InstallAllSkills()
if installResult == nil {
return &SyncResult{
Action: "fallback_failed",
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
Detail: reason,
Force: opts.Force,
}
}
if installResult.Err != nil {
return &SyncResult{
Action: "fallback_failed",
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
Detail: reason + "\n" + resultDetail(installResult),
Force: opts.Force,
}
}
state := SkillsState{
Version: opts.Version,
OfficialSkills: official,
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{
Action: "fallback_synced",
Official: official,
Updated: official,
Added: official,
SkippedDeleted: []string{},
Detail: reason + "\nstate write failed: " + writeErr.Error(),
Force: opts.Force,
}
}
return &SyncResult{
Action: "fallback_synced",
Official: official,
Updated: official,
Added: official,
SkippedDeleted: []string{},
Detail: reason,
Force: opts.Force,
}
}
func resultDetail(result *selfupdate.NpmResult) string {
if result == nil {
return ""
}
parts := []string{}
if output := strings.TrimSpace(result.CombinedOutput()); output != "" {
parts = append(parts, output)
}
if result.Err != nil {
parts = append(parts, result.Err.Error())
}
return strings.Join(parts, "\n")
}
func uniqueSorted(values []string) []string {
return sortedKeys(toSet(values))
}
func toSet(values []string) map[string]bool {
out := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
out[value] = true
}
}
return out
}
// result = { x | x ∈ values ∧ x ∈ allowed }
func intersection(values []string, allowed map[string]bool) []string {
out := map[string]bool{}
for _, value := range values {
if allowed[value] {
out[value] = true
}
}
return sortedKeys(out)
}
func sortedKeys(values map[string]bool) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,517 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillscheck
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/selfupdate"
)
func TestParseSkillsListIgnoresUnsupportedFormat(t *testing.T) {
input := `Installed skills:
- lark-calendar
- lark-mail
lark-im
custom-skill
lark-base@1.0.0
lark-cli-harness:dev@0.1.0
`
got := ParseSkillsList(input)
if len(got) != 0 {
t.Fatalf("ParseSkillsList() = %#v, want empty result for unsupported format", got)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
lark-approval ~/.agents/skills/lark-approval
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
lark-attendance ~/.agents/skills/lark-attendance
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
lark-base ~/.agents/skills/lark-base
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
lark-calendar ~/.agents/skills/lark-calendar
Agents: TRAE CN, TRAE, TRAE-SOLO, TRAE CLI, TRAE CLI (Coco) +3 more
dogfood ~/.hermes/skills/dogfood
Agents: Hermes Agent
yuanbao ~/.hermes/skills/yuanbao
Agents: Hermes Agent
`
got := ParseSkillsList(input)
want := []string{"dogfood", "lark-approval", "lark-attendance", "lark-base", "lark-calendar", "yuanbao"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsListWithANSI(t *testing.T) {
input := "\x1b[1mGlobal Skills\x1b[0m\n\n" +
"\x1b[36mlark-calendar\x1b[0m \x1b[38;5;102m~/.agents/skills/lark-calendar\x1b[0m\n" +
" \x1b[38;5;102mAgents:\x1b[0m TRAE CN, TRAE +3 more\n" +
"\x1b[36mdogfood\x1b[0m \x1b[38;5;102m~/.hermes/skills/dogfood\x1b[0m\n" +
" \x1b[38;5;102mAgents:\x1b[0m Hermes Agent\n" +
"\nTip: Use the -y flag to run in non-interactive mode (for CI and AI agents).\n"
got := ParseSkillsList(input)
want := []string{"dogfood", "lark-calendar"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (ANSI Global Skills) = %#v, want %#v", got, want)
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar", "lark-custom"},
PreviousState: previous,
StateReadable: true,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-new"})
assertStrings(t, got.Added, []string{"lark-new"})
assertStrings(t, got.SkippedDeleted, []string{"lark-mail"})
}
func TestPlanNormal_MissingStateInstallsAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
StateReadable: false,
Force: false,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.SkippedDeleted, []string{})
}
func TestPlanForceRestoresAllOfficial(t *testing.T) {
got := PlanSync(SyncInput{
Version: "1.0.33",
OfficialSkills: []string{"lark-calendar", "lark-mail", "lark-new"},
LocalSkills: []string{"lark-calendar"},
PreviousState: &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}},
StateReadable: true,
Force: true,
})
assertStrings(t, got.ToUpdate, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, got.Added, []string{})
assertStrings(t, got.SkippedDeleted, []string{})
}
type fakeSkillsRunner struct {
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
}
func officialSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Available Skills\n")
for _, name := range names {
b.WriteString("│ ")
b.WriteString(name)
b.WriteString("\n")
}
return b.String()
}
func globalSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Global Skills\n\n")
for _, name := range names {
b.WriteString(name)
b.WriteString(" ~/.agents/skills/")
b.WriteString(name)
b.WriteString("\n Agents: Claude Code\n")
}
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
return r
}
func (f *fakeSkillsRunner) InstallSkill(nameList []string) *selfupdate.NpmResult {
f.installed = append(f.installed, nameList)
r := &selfupdate.NpmResult{}
r.Err = f.installErr
return r
}
func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
f.installedAll++
r := &selfupdate.NpmResult{}
r.Err = f.installAllErr
return r
}
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
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-mail", "lark-new"})
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
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 runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
assertStrings(t, state.OfficialSkills, []string{})
}
func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_failed" {
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
}
if result.Err == nil {
t.Fatalf("SyncSkills() err = nil, want error")
}
if !strings.Contains(result.Err.Error(), "full skills install failed") {
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
}
}
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
}
}
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
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 len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1", len(runner.installed))
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
assertStrings(t, state.OfficialSkills, []string{"lark-calendar", "lark-mail"})
}
func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_failed" {
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
}
if result.Err == nil {
t.Fatalf("SyncSkills() err = nil, want error")
}
if !strings.Contains(result.Detail, "incremental boom") {
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
}
if !strings.Contains(result.Err.Error(), "full skills install failed") {
t.Fatalf("SyncSkills() err = %v, want full install failure", result.Err)
}
}
func TestSyncSkills_NilRunnerFails(t *testing.T) {
result := SyncSkills(SyncOptions{Version: "1.0.33", Now: time.Now})
if result.Err == nil || !strings.Contains(result.Err.Error(), "skills runner is nil") {
t.Fatalf("SyncSkills() err = %v, want nil runner failure", result.Err)
}
}
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
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 runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
}
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_failed" {
t.Fatalf("SyncSkills() action = %q, want fallback_failed", result.Action)
}
if result.Err == nil {
t.Fatalf("SyncSkills() err = nil, want error")
}
}
func assertStrings(t *testing.T, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
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)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
assertStrings(t, state.OfficialSkills, []string{})
assertStrings(t, state.UpdatedSkills, []string{})
assertStrings(t, state.AddedOfficialSkills, []string{})
}
func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
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)
}
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-mail"})
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-mail"})
assertStrings(t, state.AddedOfficialSkills, []string{"lark-calendar", "lark-mail"})
}
func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
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)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if !strings.Contains(result.Detail, "incremental boom") {
t.Fatalf("SyncSkills() detail = %q, want incremental error text", result.Detail)
}
}
func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result1.Action != "fallback_synced" {
t.Fatalf("first sync: action = %q, want fallback_synced", result1.Action)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() after first sync = (_, %v, %v), want readable", readable, err)
}
if state.Version != "1.0.33" {
t.Fatalf("state.Version = %q, want %q", state.Version, "1.0.33")
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {
t.Fatalf("second sync: action = %q, want synced (no fallback loop)", result2.Action)
}
if runner2.installedAll != 0 {
t.Fatalf("second sync: installedAll = %d, want 0 (incremental, not fallback)", runner2.installedAll)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.40",
"version": "1.0.41",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -73,6 +73,9 @@ var urlPathToType = []struct {
Type string
}{
{"/drive/folder/", "folder"},
{"/drive/file/", "file"},
{"/drive/shr/", "folder"},
{"/chat/drive/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},

View File

@@ -28,6 +28,9 @@ func TestParseResourceURL(t *testing.T) {
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
{"file", "https://xxx.feishu.cn/file/boxcnABC", "file", "boxcnABC", true},
{"folder", "https://xxx.feishu.cn/drive/folder/fldcnABC", "folder", "fldcnABC", true},
{"file via /drive/file/", "https://feishu.doubao.com/drive/file/boxcnABC", "file", "boxcnABC", true},
{"folder via /chat/drive/", "https://feishu.doubao.com/chat/drive/fldcnABC", "folder", "fldcnABC", true},
{"folder via /drive/shr/", "https://feishu.doubao.com/drive/shr/fldcnABC", "folder", "fldcnABC", true},
{"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true},
{"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true},

View File

@@ -109,6 +109,45 @@ func TestDriveInspectValidate_ValidWikiURL(t *testing.T) {
}
}
func TestDriveInspectValidate_ValidDoubaoDriveFileURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestDriveInspectValidate_ValidDoubaoChatDriveFolderURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/chat/drive/fldcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestDriveInspectValidate_ValidDoubaoDriveShareFolderURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
// --- DryRun tests ---
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
@@ -235,6 +274,82 @@ func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
}
}
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/file/boxcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
if !ok || len(reqDocs) != 1 {
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
}
doc, _ := reqDocs[0].(map[string]interface{})
if doc["doc_token"] != "boxcnABC" {
t.Errorf("doc_token = %v, want boxcnABC", doc["doc_token"])
}
if doc["doc_type"] != "file" {
t.Errorf("doc_type = %v, want file", doc["doc_type"])
}
}
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://feishu.doubao.com/drive/shr/fldcnABC")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
dry := DriveInspect.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
reqDocs, ok := got.API[0].Body["request_docs"].([]interface{})
if !ok || len(reqDocs) != 1 {
t.Fatalf("expected request_docs with 1 entry, got %v", got.API[0].Body["request_docs"])
}
doc, _ := reqDocs[0].(map[string]interface{})
if doc["doc_token"] != "fldcnABC" {
t.Errorf("doc_token = %v, want fldcnABC", doc["doc_token"])
}
if doc["doc_type"] != "folder" {
t.Errorf("doc_type = %v, want folder", doc["doc_type"])
}
}
// --- Execute tests ---
func TestDriveInspectExecute_DocxURL(t *testing.T) {

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
minutesSpeakerReplaceSpeakerNotFoundCode = 2091001
minutesSpeakerReplaceNoEditPermission = 2091005
)
// MinutesSpeakerReplace replaces a speaker in a minute's transcript.
var MinutesSpeakerReplace = common.Shortcut{
Service: "minutes",
Command: "+speaker-replace",
Description: "Replace a speaker in a minute's transcript (rebind from one user to another)",
Risk: "write",
Scopes: []string{"minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true},
{Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
}
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID == "" {
return output.ErrValidation("--from-user-id is required")
}
if _, err := common.ValidateUserID(fromUserID); err != nil {
return output.ErrValidation("--from-user-id: %s", err)
}
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
if toUserID == "" {
return output.ErrValidation("--to-user-id is required")
}
if _, err := common.ValidateUserID(toUserID); err != nil {
return output.ErrValidation("--to-user-id: %s", err)
}
if fromUserID == toUserID {
return output.ErrValidation("--from-user-id and --to-user-id must be different")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(map[string]interface{}{
"minute_token": minuteToken,
"from_user_id": fromUserID,
"to_user_id": toUserID,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
body := map[string]interface{}{
"minute_token": minuteToken,
"from_user_id": fromUserID,
"to_user_id": toUserID,
}
_, err := runtime.CallAPI(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, fromUserID)
}
outData := map[string]interface{}{
"minute_token": minuteToken,
"from_user_id": fromUserID,
"to_user_id": toUserID,
}
runtime.OutFormat(outData, nil, nil)
return nil
},
}
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
switch exitErr.Detail.Code {
case minutesSpeakerReplaceNoEditPermission:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesSpeakerReplaceNoEditPermission,
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
case minutesSpeakerReplaceSpeakerNotFoundCode:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "speaker_not_found",
Code: minutesSpeakerReplaceSpeakerNotFoundCode,
Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID),
Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
}
return err
}

View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
const minutesSpeakerReplaceTestToken = "obcnexampleminute"
func TestMinutesSpeakerReplace_Validate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing minute token",
args: []string{"+speaker-replace", "--from-user-id", "ou_a", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "required flag(s) \"minute-token\" not set",
},
{
name: "missing from",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "required flag(s) \"from-user-id\" not set",
},
{
name: "missing to",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--as", "user"},
wantErr: "required flag(s) \"to-user-id\" not set",
},
{
name: "invalid from prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "--from-user-id",
},
{
name: "invalid to prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
wantErr: "--to-user-id",
},
{
name: "from equals to",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"},
wantErr: "must be different",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesSpeakerReplace.Mount(parent, f)
parent.SetArgs(tt.args)
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
}
})
}
}
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT method, got:\n%s", out)
}
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesSpeakerReplaceTestToken+"/transcript/speaker") {
t.Errorf("expected speaker endpoint, got:\n%s", out)
}
if !strings.Contains(out, "ou_old_speaker") {
t.Errorf("expected from_user_id in body, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.MinuteToken != minutesSpeakerReplaceTestToken {
t.Errorf("data.minute_token = %q, want %q", envelope.Data.MinuteToken, minutesSpeakerReplaceTestToken)
}
if envelope.Data.FromUserID != "ou_old_speaker" {
t.Errorf("data.from_user_id = %q, want ou_old_speaker", envelope.Data.FromUserID)
}
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
}
func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
Body: map[string]interface{}{
"code": 2091001,
"msg": "speaker not exist",
},
})
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_missing_speaker",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected speaker-not-found error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if exitErr.Detail.Type != "speaker_not_found" {
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "Speaker not found") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") {
t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") {
t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint)
}
}
func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
Body: map[string]interface{}{
"code": 2091005,
"msg": "no edit permission",
},
})
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected no-edit-permission error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) {
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const minutesUpdateNoEditPermissionCode = 2091005
// MinutesUpdate updates the title (topic) of a minute.
var MinutesUpdate = common.Shortcut{
Service: "minutes",
Command: "+update",
Description: "Update a minute's title",
Risk: "write",
Scopes: []string{"minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "topic", Desc: "new minute title", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
}
if strings.TrimSpace(runtime.Str("topic")) == "" {
return output.ErrValidation("--topic is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken))).
Body(map[string]interface{}{"topic": runtime.Str("topic")})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
topic := runtime.Str("topic")
body := map[string]interface{}{
"topic": topic,
}
_, err := runtime.CallAPI(http.MethodPatch,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
return minutesUpdateError(err, minuteToken)
}
outData := map[string]interface{}{
"minute_token": minuteToken,
"topic": topic,
}
runtime.OutFormat(outData, nil, nil)
return nil
},
}
func minutesUpdateError(err error, minuteToken string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode {
return err
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesUpdateNoEditPermissionCode,
Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
const minutesUpdateTestToken = "obcnexampleminute"
func TestMinutesUpdate_Validate(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing minute token",
args: []string{"+update", "--topic", "new title", "--as", "user"},
wantErr: "required flag(s) \"minute-token\" not set",
},
{
name: "missing topic",
args: []string{"+update", "--minute-token", "obcn123456", "--as", "user"},
wantErr: "required flag(s) \"topic\" not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesUpdate.Mount(parent, f)
parent.SetArgs(tt.args)
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error should contain %q, got: %s", tt.wantErr, err.Error())
}
})
}
}
func TestMinutesUpdate_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesUpdate, []string{
"+update",
"--minute-token", minutesUpdateTestToken,
"--topic", "周会纪要",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "PATCH") {
t.Errorf("expected PATCH method, got:\n%s", out)
}
if !strings.Contains(out, "/open-apis/minutes/v1/minutes/"+minutesUpdateTestToken) {
t.Errorf("expected PATCH /open-apis/minutes/v1/minutes/<token>, got:\n%s", out)
}
if !strings.Contains(out, "周会纪要") {
t.Errorf("expected topic in body, got:\n%s", out)
}
}
func TestMinutesUpdate_Execute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPatch,
URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, MinutesUpdate, []string{
"+update",
"--minute-token", minutesUpdateTestToken,
"--topic", "新标题",
"--format", "json", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestMinutesUpdate_NoEditPermission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodPatch,
URL: "/open-apis/minutes/v1/minutes/" + minutesUpdateTestToken,
Body: map[string]interface{}{
"code": 2091005,
"msg": "no edit permission",
},
})
err := mountAndRun(t, MinutesUpdate, []string{
"+update",
"--minute-token", minutesUpdateTestToken,
"--topic", "新标题",
"--format", "json", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected no-edit-permission error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) {
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
}
}

View File

@@ -11,5 +11,7 @@ func Shortcuts() []common.Shortcut {
MinutesSearch,
MinutesDownload,
MinutesUpload,
MinutesUpdate,
MinutesSpeakerReplace,
}
}

View File

@@ -414,6 +414,9 @@ func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, re
if chapters, ok := data["minute_chapters"].([]any); ok && len(chapters) > 0 {
result["chapters"] = chapters
}
if keywords, ok := data["keywords"].([]any); ok && len(keywords) > 0 {
result["keywords"] = keywords
}
}
// parseArtifactType extracts artifact_type as int from varying JSON number representations.

View File

@@ -126,6 +126,7 @@ func artifactsStub(token string) *httpmock.Stub {
"summary": "Test summary content",
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
"keywords": []interface{}{"budget", "roadmap"},
},
},
}

View File

@@ -59,14 +59,12 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
## 查看完整参数定义
lark-cli schema calendar.events.create
## 创建日程
lark-cli calendar events create --calendar-id primary --data '{
"summary": "产品评审",
"description": "本周分享主题CLI 架构设计",
lark-cli calendar events create \
--params '{"calendar_id":"<CALENDAR_ID>"}' \
--data '{
"summary": "技术分享CLI 架构设计",
"start_time": { "timestamp": "1741586400" },
"end_time": { "timestamp": "1741593600" },
"location": { "name": "5F-大会议室" },
"attendee_ability": "can_modify_event",
"reminders": [{ "minutes": 15 }]
"end_time": { "timestamp": "1741593600" }
}'
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id
@@ -74,7 +72,7 @@ lark-cli calendar events create --calendar-id primary --data '{
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程
@@ -82,8 +80,7 @@ lark-cli calendar event.attendees create \
lark-cli schema calendar.events.delete
## 删除空日程
lark-cli calendar events delete \
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
--params '{"need_notification":false}'
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
```

View File

@@ -1,6 +1,6 @@
---
name: lark-doc
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML也支持 Markdown。"
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。"
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。当用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是资源类型、URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg``whisper` 等本地转写命令。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -98,6 +98,8 @@ Minutes (妙记) ← minute_token 标识
> - 用户说"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
## Shortcuts推荐优先使用
@@ -108,10 +110,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
| [`+update`](references/lark-minutes-update.md) | Update a minute's title |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) |
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。
- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID不支持姓名
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
@@ -135,5 +141,7 @@ lark-cli minutes <resource> <method> [flags] # 调用 API
| `+search` | `minutes:minutes.search:read` |
| `minutes.get` | `minutes:minutes:readonly` |
| `+download` | `minutes:minutes.media:export` |
| `+update` | `minutes:minutes:update` |
| `+speaker-replace` | `minutes:minutes:update` |
<!-- AUTO-GENERATED-END -->

View File

@@ -0,0 +1,50 @@
# minutes +speaker-replace
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。
本 skill 对应 shortcut`lark-cli minutes +speaker-replace`
## 典型触发表达
- "把这条妙记里 A 的发言改成 B"
- "妙记说话人识别错了,帮我把张三的部分换成李四"
- "妙记说话人修改 / 替换 / 重新归属"
- "改一下妙记的说话人"
## 命令示例
```bash
lark-cli minutes +speaker-replace \
--minute-token obcnxxxxxxxxxxxxxxxxxxxx \
--from-user-id ou_old_speaker_open_id \
--to-user-id ou_new_speaker_open_id
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
| `--from-user-id <ou_xxx>` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
| `--to-user-id <ou_xxx>` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 |
> **重要**`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。
## 认证与权限
- 所需 scope`minutes:minutes:update`
## 输出结果
| 字段 | 说明 |
|------|------|
| `minute_token` | 被修改的妙记 Token与输入的 `--minute-token` 一致 |
| `from_user_id` | 被替换的原说话人 open_id与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 |
| `to_user_id` | 替换后的新说话人 open_id与输入的 `--to-user-id` 一致 |
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,41 @@
# minutes +update
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
修改飞书妙记的标题topic
本 skill 对应 shortcut`lark-cli minutes +update`
## 典型触发表达
- "把这个妙记的标题改成 xxx"
- "重命名这条妙记"
- "修改妙记标题"
## 命令示例
```bash
lark-cli minutes +update --minute-token xxx --topic "周会纪要 2026-05-18"
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 |
| `--topic <string>` | 是 | 新的妙记标题 |
## 认证与权限
- 所需 scope`minutes:minutes:update`
## 输出结果
| 字段 | 说明 |
|------|------|
| `minute_token` | 被修改的妙记 Token与输入的 `--minute-token` 一致,可继续用于查询妙记信息、下载媒体或获取纪要产物 |
| `topic` | 修改后的妙记标题,与输入的 `--topic` 一致 |
## 参考
- [lark-minutes](../SKILL.md) -- 妙记相关功能说明
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1,7 +1,7 @@
---
name: lark-sheets
version: 1.2.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。"
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。"
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]

View File

@@ -13,18 +13,24 @@ If the user query only specifies a task name (e.g., "Complete task Lobster No. 1
List tasks assigned to the current user, with support for filtering by completion status, creation time, and due date.
By default, the command will automatically paginate up to 20 times. Use `--page-all` to fetch more (up to 40 pages).
> **Pending vs all tasks:** When `--complete` is not provided, the result contains **both completed and incomplete tasks**.
> For standup / daily-summary / pending-todo scenarios, you **must** pass `--complete=false`; otherwise completed tasks will be surfaced as if they were still pending.
## Recommended Commands
```bash
# Search for a specific task by name
lark-cli task +get-my-tasks --query "Lobster No. 1"
# Get all my tasks (fetches up to 20 pages by default)
# Get all my tasks, both completed and incomplete (fetches up to 20 pages by default)
lark-cli task +get-my-tasks
# Get my incomplete tasks (fetches up to 20 pages by default)
# Pending-only: my incomplete tasks (use this for standup/daily-summary)
lark-cli task +get-my-tasks --complete=false
# Pending-only with a due-date upper bound (e.g. end of today / this week)
lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00"
# Fetch all my tasks (up to 40 pages)
lark-cli task +get-my-tasks --page-all

View File

@@ -96,7 +96,8 @@ Meeting (视频会议)
├── Transcript (文字记录)
├── Summary (总结)
├── Todos (待办)
── Chapters (章节)
── Chapters (章节)
└── Keywords (推荐关键词)
```
> **注意**`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。

View File

@@ -93,6 +93,7 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
| `artifacts.summary` | AI 总结JSON 内联) |
| `artifacts.todos` | 待办事项JSON 内联) |
| `artifacts.chapters` | 章节纪要JSON 内联) |
| `artifacts.keywords` | 妙记推荐关键词JSON 内联) |
| `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` |
## 如何获取输入参数

View File

@@ -1,7 +1,7 @@
---
name: lark-wiki
version: 1.0.0
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。"
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]
@@ -116,4 +116,3 @@ lark-cli wiki <resource> <method> [flags] # 调用 API
| `nodes.move` | `wiki:node:move` |
| `nodes.create` | `wiki:node:create` |
| `nodes.list` | `wiki:node:retrieve` |

View File

@@ -30,8 +30,8 @@ lark-cli auth login --domain calendar,task
## 工作流
```
{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件)
└─► task +get-my-tasks [--due-end] ──► 未完成待办列表
{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件)
└─► task +get-my-tasks --complete=false [--due-end] ──► 未完成待办列表
AI 汇总(时间转换 + 冲突检测 + 排序)──► 摘要
@@ -54,19 +54,21 @@ lark-cli calendar +agenda --start "2026-03-26T00:00:00+08:00" --end "2026-03-26T
### Step 2: 获取未完成待办
```bash
# 默认:返回分配给当前用户的未完成任务(最多 20 条)
lark-cli task +get-my-tasks
# 默认 pending 摘要:必须显式过滤未完成任务(最多 20 条)
lark-cli task +get-my-tasks --complete=false
# 只看指定日期前到期的(推荐用于摘要场景,减少数据量)
lark-cli task +get-my-tasks --due-end "2026-03-27T23:59:59+08:00"
# 只看指定日期前到期的未完成任务(推荐用于摘要场景,减少数据量)
lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00"
# 获取全部(超过 20 条时)
lark-cli task +get-my-tasks --page-all
# 获取全部未完成任务(超过 20 条时)
lark-cli task +get-my-tasks --complete=false --page-all
```
> **注意**不带过滤条件时可能返回大量历史待办(实测 30+ 条、100KB+),容易超出上下文限制。摘要场景建议:
> **注意**`+get-my-tasks` 不带 `--complete` 时会**同时返回已完成和未完成任务**,会把已完成任务当成"待办"展示进摘要里。站会/日报这种 pending 汇总场景**必须**显式带上 `--complete=false`,不要省略。
>
> 数据量层面也建议加过滤:
> - 用 `--due-end` 过滤出目标日期前到期的任务
> - 如果也需要无截止日期的任务,可不加过滤,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办"
> - 如果也需要无截止日期的任务,可不加 `--due-end`,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办"
### Step 3: AI 汇总

View File

@@ -45,12 +45,30 @@ func TestDriveInspectDryRun_FileURL(t *testing.T) {
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/file/boxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_FolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/drive/folder/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoChatDriveFolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/chat/drive/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/shr/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_MindnoteURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/mindnote/mncnDryRunE2E")

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"minutes", "+speaker-replace",
"--minute-token", "obcnexampleminute",
"--from-user-id", "ou_old_speaker",
"--to-user-id", "ou_new_speaker",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, "ou_old_speaker"), "dry-run should contain from_user_id, got: %s", output)
assert.True(t, strings.Contains(output, "ou_new_speaker"), "dry-run should contain to_user_id, got: %s", output)
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMinutesUpdate_DryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"minutes", "+update",
"--minute-token", "obcnexampleminute",
"--topic", "新的妙记标题",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "PATCH"), "dry-run should contain PATCH method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, "新的妙记标题"), "dry-run should contain topic, got: %s", output)
}