Compare commits

..

1 Commits

Author SHA1 Message Date
zhangjun.1
58466a3d94 feat: replace summary and todo 2026-06-03 10:52:42 +08:00
57 changed files with 1134 additions and 3328 deletions

View File

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

View File

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

View File

@@ -33,7 +33,6 @@ 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

@@ -1,73 +0,0 @@
// 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

@@ -1,28 +0,0 @@
// 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,8 +384,11 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
})
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
// produces no skills key in the composed notice.
// 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.
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
@@ -416,13 +419,13 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
}
}
// TestSetupNotices_InSync verifies that matching state produces no
// TestSetupNotices_InSync verifies that a matching stamp 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.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
if err := skillscheck.WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
@@ -449,13 +452,13 @@ func TestSetupNotices_InSync(t *testing.T) {
}
}
// TestSetupNotices_Drift verifies mismatching state produces the
// TestSetupNotices_Drift verifies a mismatching stamp 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.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
@@ -504,7 +507,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
clearNoticeEnv(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}

View File

@@ -31,18 +31,15 @@ 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 state comparison.
// normalizeVersion canonicalizes a version string for stamp 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 {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
return strings.TrimPrefix(s, "V")
return strings.TrimPrefix(strings.TrimSpace(s), "v")
}
func releaseURL(version string) string {
@@ -124,9 +121,7 @@ func updateRun(opts *UpdateOptions) error {
cur := currentVersion()
updater := newUpdater()
if !opts.Check {
updater.CleanupStaleFiles()
}
updater.CleanupStaleFiles()
output.PendingNotice = nil
// 1. Fetch latest version
@@ -142,9 +137,13 @@ func updateRun(opts *UpdateOptions) error {
// 3. Compare versions
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
// 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
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
skillsResult = runSkillsAndStamp(updater, io, cur, opts.Force)
}
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
@@ -186,7 +185,16 @@ 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(),
}
applySkillsStatus(out, cur)
// 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,
}
}
output.PrintJson(io.Out, out)
return nil
}
@@ -202,7 +210,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
}
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
skillsResult := runSkillsAndStamp(updater, io, cur, opts.Force)
reason := detect.ManualReason()
if opts.JSON {
@@ -280,7 +288,10 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
return output.ErrBare(output.ExitAPI)
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
// 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)
if opts.JSON {
result := map[string]interface{}{
@@ -317,21 +328,27 @@ 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))
}
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
// 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 {
if !force {
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
if existing, _ := skillscheck.ReadStamp(); normalizeVersion(existing) == normalizeVersion(stampVersion) {
return nil
}
}
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)
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)
}
}
return result
return r
}
// reportAlreadyUpToDate emits the JSON / pretty output for the
@@ -339,7 +356,7 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
// 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 *skillscheck.SyncResult, check bool) error {
func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, skillsResult *selfupdate.NpmResult, check bool) error {
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
@@ -347,7 +364,16 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
"message": fmt.Sprintf("lark-cli %s is already up to date", cur),
}
if check {
applySkillsStatus(out, cur)
// 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,
}
}
} else {
applySkillsResult(out, skillsResult)
}
@@ -361,70 +387,36 @@ func reportAlreadyUpToDate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, late
return nil
}
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) {
// 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) {
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)
env["skills_summary"] = skillsSummary(r)
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
env["skills_detail"] = selfupdate.Truncate(detail, maxNpmOutput)
}
default:
env["skills_action"] = "synced"
env["skills_summary"] = skillsSummary(r)
}
}
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) {
// emitSkillsTextHints prints human-readable feedback about the skills
// sync result for non-JSON output.
func emitSkillsTextHints(io *cmdutil.IOStreams, r *selfupdate.NpmResult) {
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 len(r.Failed) > 0 {
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
if detail := strings.TrimSpace(r.Stderr.String()); detail != "" {
fmt.Fprintf(io.ErrOut, " %s\n", selfupdate.Truncate(detail, maxStderrDetail))
}
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))
fmt.Fprintf(io.ErrOut, " Run manually: npx -y skills add larksuite/cli -g -y\n")
default:
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
if len(r.SkippedDeleted) > 0 {
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
}
fmt.Fprintf(io.ErrOut, "%s Skills updated\n", symOK())
}
}

View File

@@ -5,14 +5,13 @@ package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -29,6 +28,7 @@ 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,53 +41,22 @@ 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) {
func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult,
npmFn func(string) *selfupdate.NpmResult,
skillsFn func() *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)
@@ -199,7 +168,9 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir because skills sync writes skills-state.json.
// 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".
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -215,6 +186,7 @@ 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()
@@ -244,6 +216,7 @@ 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()
@@ -257,7 +230,7 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same state-isolation rationale as TestUpdateNpm_JSON.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -273,6 +246,7 @@ 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()
@@ -349,7 +323,7 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same state-isolation rationale as TestUpdateNpm_JSON.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -365,6 +339,7 @@ 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()
@@ -476,8 +451,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.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
t.Fatal("skills update should not run when binary verification fails")
return nil
}
return u
@@ -674,7 +649,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 state-isolation rationale as TestUpdateNpm_JSON.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -693,6 +668,7 @@ 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()
@@ -774,6 +750,7 @@ 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()
@@ -808,7 +785,8 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
// Skills update fails
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -834,8 +812,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_summary") {
t.Errorf("expected skills_summary in output, got: %s", out)
if !strings.Contains(out, "skills_detail") {
t.Errorf("expected skills_detail in output, got: %s", out)
}
}
@@ -860,7 +838,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.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
u.SkillsUpdateOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
r.Err = fmt.Errorf("exit status 127")
@@ -883,96 +861,100 @@ 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, "lark-cli update --force") {
t.Errorf("expected force retry hint, got: %s", out)
if !strings.Contains(out, "npx -y skills add") {
t.Errorf("expected manual skills command hint, got: %s", out)
}
}
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable
// for direct calls to internals like runSkillsAndStamp that write to
// io.ErrOut.
func newTestIO() *cmdutil.IOStreams {
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
}
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 {
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 {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
called = true
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
if got != nil {
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got)
}
if called {
t.Error("SkillsCommandOverride called, want skipped due to dedup")
t.Error("SkillsUpdateOverride called, want skipped due to dedup")
}
}
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 {
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 {
t.Fatal(err)
}
called := false
updater := &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
return &selfupdate.NpmResult{}
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true)
if got == nil {
t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil")
}
if !called {
t.Error("SkillsCommandOverride not called with force=true")
t.Error("SkillsUpdateOverride not called with force=true")
}
}
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)
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)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got)
}
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)
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
}
}
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 {
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 {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
return r
},
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err == nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got)
}
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)
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.20" {
t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp)
}
}
@@ -991,7 +973,8 @@ func TestTruncate(t *testing.T) {
}
func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origFetch := fetchLatest
origCur := currentVersion
@@ -1004,9 +987,9 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
return &selfupdate.NpmResult{}
},
}
}
@@ -1017,19 +1000,17 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("skills sync not called in already-up-to-date branch")
t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called")
}
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)
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\"", stamp)
}
}
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origFetch := fetchLatest
origCur := currentVersion
@@ -1048,9 +1029,9 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
ResolvedPath: "/usr/local/bin/lark-cli",
}
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
return &selfupdate.NpmResult{}
},
}
}
@@ -1061,19 +1042,17 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("skills sync not called in manual branch")
t.Error("RunSkillsUpdate not called in manual branch, want called")
}
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)
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.21" {
t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp)
}
}
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
origFetch := fetchLatest
origCur := currentVersion
@@ -1096,9 +1075,9 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
return &selfupdate.NpmResult{}
},
VerifyOverride: func(expectedVersion string) error { return nil },
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
return &selfupdate.NpmResult{}
},
}
}
@@ -1109,25 +1088,18 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Fatalf("updateRun() err = %v, want nil", err)
}
if !skillsCalled {
t.Error("skills sync not called in npm branch")
t.Error("RunSkillsUpdate not called in npm branch")
}
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)
stamp, _ := skillscheck.ReadStamp()
if stamp != "1.0.22" {
t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp)
}
}
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
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 {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
@@ -1145,9 +1117,9 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
return &selfupdate.NpmResult{}
},
}
}
@@ -1158,7 +1130,7 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
t.Fatalf("updateRun(--check) err = %v, want nil", err)
}
if skillsCalled {
t.Error("skills sync called under --check, want skipped")
t.Error("RunSkillsUpdate called under --check, want skipped (pure report)")
}
var env map[string]interface{}
@@ -1172,14 +1144,12 @@ 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) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := skillscheck.WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
@@ -1194,9 +1164,9 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
SkillsUpdateOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
return &selfupdate.NpmResult{}
},
}
}
@@ -1207,15 +1177,12 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err)
}
if skillsCalled {
t.Error("skills sync called under --check (already-latest), want skipped")
t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)")
}
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)
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)
}
var env map[string]interface{}
@@ -1237,248 +1204,39 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
}
}
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")}
// 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)
}
t.Cleanup(func() { syncSkills = origSync })
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath)
f, _, stderr := newTestFactory(t)
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)
updater := &selfupdate.Updater{
SkillsUpdateOverride: func() *selfupdate.NpmResult {
return &selfupdate.NpmResult{} // success
},
}
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
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") {
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, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success
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,7 +41,6 @@ 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,12 +43,6 @@ 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()
@@ -91,7 +85,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: errKeychainBlocked}
resCh <- result{key: nil, err: errors.New("keychain access blocked")}
return
}
@@ -123,7 +117,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, errKeychainBlocked
return nil, errors.New("keychain access blocked")
}
}
@@ -271,7 +265,11 @@ func platformGet(service, account string) (string, error) {
if err != nil {
return "", err
}
return decryptData(data, key)
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
}
return plaintext, nil
}
// platformSet stores a value in the macOS keychain.
@@ -318,116 +316,3 @@ 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,10 +10,8 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/zalando/go-keyring"
)
@@ -113,305 +111,6 @@ 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

@@ -1,10 +0,0 @@
// 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 / SkillsCommandOverride / VerifyOverride
// Override DetectOverride / NpmInstallOverride / SkillsUpdateOverride / VerifyOverride
// / RestoreAvailableOverride for testing.
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
SkillsUpdateOverride func() *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -153,27 +153,12 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
// 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()
}
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")
@@ -182,28 +167,6 @@ func (u *Updater) InstallAllSkills() *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 {
@@ -212,7 +175,7 @@ func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd := exec.CommandContext(ctx, npxPath, "-y", "skills", "add", source, "-g", "-y")
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -8,7 +8,6 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
@@ -167,87 +166,3 @@ 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,29 +3,46 @@
package skillscheck
import "strings"
// Init runs the synchronous skills version check. Stores a StaleNotice when
// the local skills state records a version that does not match currentVersion.
// Safe to call from cmd/root.go before rootCmd.Execute(); zero network, zero
// subprocess — only a local state file read.
// 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.
//
// 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
}
version, ok := ReadSyncedVersion()
if !ok {
stamp, err := ReadStamp()
if err != nil {
// Fail closed — don't nag for a transient FS problem.
return
}
if strings.TrimPrefix(strings.TrimPrefix(version, "v"), "V") == strings.TrimPrefix(strings.TrimPrefix(currentVersion, "v"), "V") {
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 {
return
}
SetPending(&StaleNotice{
Current: version,
Current: stamp, // guaranteed non-empty under the new contract
Target: currentVersion,
})
}

View File

@@ -18,8 +18,9 @@ func resetPending(t *testing.T) {
func TestInit_InSync_NoNotice(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := WriteState(SkillsState{Version: "1.0.21"}); err != nil {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.21"); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -38,24 +39,12 @@ func TestInit_ColdStart_NoNotice(t *testing.T) {
}
}
func TestInit_NormalizedVersion_NoNotice(t *testing.T) {
func TestInit_Drift_NoticeWithStampVersion(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
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 {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteStamp("1.0.20"); err != nil {
t.Fatal(err)
}
Init("1.0.21")
@@ -72,18 +61,22 @@ 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_ReadStateError_FailsClosed(t *testing.T) {
func TestInit_ReadStampError_FailsClosed(t *testing.T) {
clearSkillsSkipEnv(t)
resetPending(t)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := os.MkdirAll(filepath.Join(dir, "skills-state.json"), 0o755); err != nil {
// 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 {
t.Fatal(err)
}
Init("1.0.21")

View File

@@ -3,8 +3,9 @@
// 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 skills-state.json. On mismatch it
// stores a notice for injection into JSON envelopes via output.PendingNotice.
// 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.
package skillscheck
import (
@@ -25,7 +26,8 @@ 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.
// non-empty because Init only emits a StaleNotice for the drift case
// (stamp present and != binary version).
func (s *StaleNotice) Message() string {
return fmt.Sprintf(
"lark-cli skills %s out of sync with binary %s, run: lark-cli update",

View File

@@ -0,0 +1,49 @@
// 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

@@ -0,0 +1,113 @@
// 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

@@ -1,92 +0,0 @@
// 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

@@ -1,139 +0,0 @@
// 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

@@ -1,399 +0,0 @@
// 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

@@ -1,517 +0,0 @@
// 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.41",
"version": "1.0.40",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -73,9 +73,6 @@ 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,9 +28,6 @@ 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,45 +109,6 @@ 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) {
@@ -274,82 +235,6 @@ 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

@@ -1,139 +0,0 @@
// 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

@@ -1,247 +0,0 @@
// 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,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const minutesSummaryMarkdownTip = "Summary accepts any text; unsupported Markdown is saved but may display as literal raw text in Minutes. For best rendering, prefer plain text, line breaks, headings (#, ##, ###), bold (**text**), and lists (-, *, or 1.)."
// MinutesSummary replaces the AI summary of a minute.
var MinutesSummary = common.Shortcut{
Service: "minutes",
Command: "+summary",
Description: "Replace the AI summary of a minute",
Risk: "write",
Scopes: []string{"minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token", Required: true},
{Name: "summary", Desc: "replacement summary text (Markdown subset renders best in Minutes)", Required: true, Input: []string{common.File, common.Stdin}},
},
Tips: []string{
minutesSummaryMarkdownTip,
"Use `lark-cli vc +notes --minute-tokens <token>` to read the current summary before replacing it.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := 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)
}
summary := strings.TrimSpace(runtime.Str("summary"))
if summary == "" {
return output.ErrValidation("--summary is required")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/summary", validate.EncodePathSegment(runtime.Str("minute-token")))).
Body(map[string]interface{}{"summary": "<summary markdown>"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := runtime.Str("minute-token")
summary := strings.TrimSpace(runtime.Str("summary"))
path := fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/summary", validate.EncodePathSegment(minuteToken))
body := map[string]interface{}{
"summary": summary,
}
if _, err := runtime.CallAPI(http.MethodPut, path, nil, body); err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"minute_token": minuteToken,
"updated": true,
}, nil, nil)
return nil
},
}

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func todoStub(token string) *httpmock.Stub {
return &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/minutes/v1/minutes/" + token + "/todo",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
}
func firstTodoItem(t *testing.T, raw []byte) map[string]any {
t.Helper()
if len(raw) == 0 {
t.Fatal("request body was not captured")
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("failed to parse captured body: %v", err)
}
items, _ := body["todo_items"].([]any)
if len(items) != 1 {
t.Fatalf("todo_items: want 1 item, got %d (%v)", len(items), body["todo_items"])
}
item, _ := items[0].(map[string]any)
return item
}
func TestMinutesSummary_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSummary, []string{
"+summary",
"--minute-token", "obcn123456789",
"--summary", "**Weekly sync**\n- follow up",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "PUT") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/obcn123456789/summary") {
t.Fatalf("dry-run output = %q", out)
}
}
func TestMinutesTodo_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesTodo, []string{
"+todo",
"--minute-token", "obcn123456789",
"--todo", "- finish deck",
"--is-done",
"--dry-run",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "PUT") || !strings.Contains(out, "/open-apis/minutes/v1/minutes/obcn123456789/todo") {
t.Fatalf("dry-run output = %q", out)
}
if !strings.Contains(out, "todo_items") {
t.Fatalf("dry-run output should contain todo_items, got %q", out)
}
}
func TestMinutesTodo_RequiresIsDone(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesTodo, []string{
"+todo",
"--minute-token", "obcn123456789",
"--todo", "finish deck",
"--as", "user",
}, f, stderr)
if err == nil {
t.Fatal("expected validation error for missing --is-done")
}
if !strings.Contains(err.Error(), "is-done") && !strings.Contains(err.Error(), "todo-list") {
t.Fatalf("error = %q, want message mentioning is-done or todo-list", err.Error())
}
}
func TestMinutesTodo_Add_RequestBody(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
stub := todoStub("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--todo", "finish deck", "--is-done=false", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
item := firstTodoItem(t, stub.CapturedBody)
if item["content"] != "finish deck" {
t.Errorf("content = %v, want finish deck", item["content"])
}
if item["is_done"] != false {
t.Errorf("is_done = %v, want false", item["is_done"])
}
if _, ok := item["todo_id"]; ok {
t.Errorf("add should not send todo_id, got %v", item["todo_id"])
}
if !strings.Contains(stdout.String(), "add") {
t.Errorf("output should report add operation, got %q", stdout.String())
}
}
func TestMinutesTodo_Update_RequestBody(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
stub := todoStub("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--todo-id", "99", "--todo", "updated deck", "--is-done", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
item := firstTodoItem(t, stub.CapturedBody)
if item["todo_id"] != "99" {
t.Errorf("todo_id = %v, want 99", item["todo_id"])
}
if item["content"] != "updated deck" {
t.Errorf("content = %v, want updated deck", item["content"])
}
if item["is_done"] != true {
t.Errorf("is_done = %v, want true", item["is_done"])
}
}
func TestMinutesTodo_Delete_RequestBody(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
stub := todoStub("obcn123456789")
reg.Register(stub)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--todo-id", "88", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
item := firstTodoItem(t, stub.CapturedBody)
if item["todo_id"] != "88" {
t.Errorf("todo_id = %v, want 88", item["todo_id"])
}
if _, ok := item["content"]; ok {
t.Errorf("delete should not send content, got %v", item["content"])
}
if _, ok := item["is_done"]; ok {
t.Errorf("delete should not send is_done, got %v", item["is_done"])
}
// the todo id must never be surfaced to the user in the command output
if strings.Contains(stdout.String(), "88") {
t.Errorf("output must not expose the todo id, got %q", stdout.String())
}
}
func TestMinutesTodo_DeleteRejectsIsDone(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789",
"--todo-id", "88", "--is-done", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected validation error when --is-done is used to delete")
}
}
func TestMinutesTodo_RequiresAnyInput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesTodo, []string{
"+todo", "--minute-token", "obcn123456789", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected validation error when neither --todo nor --todo-id is provided")
}
}
func TestMinutesSummaryAndTodo_HelpMetadata(t *testing.T) {
for _, tip := range MinutesSummary.Tips {
if strings.Contains(tip, "raw text") {
return
}
}
t.Fatal("MinutesSummary tips should mention unsupported markdown display behavior")
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// minuteTodoOp describes the resolved single-todo operation derived from flags.
type minuteTodoOp struct {
operation string // add | update | delete
item map[string]interface{} // the single todo_items entry sent to the API
}
// MinutesTodo adds, updates, or deletes a single todo item on a minute.
var MinutesTodo = common.Shortcut{
Service: "minutes",
Command: "+todo",
Description: "Add, update, or delete a single todo item on a minute",
Risk: "write",
Scopes: []string{"minutes:minutes:update"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-token", Desc: "minute token (required)", Required: true},
{Name: "todo", Desc: "todo plain-text content; required (with --is-done) to add or update", Input: []string{common.File, common.Stdin}},
{Name: "is-done", Type: "bool", Desc: "completion flag; required together with --todo"},
{Name: "todo-id", Desc: "id of an existing todo; provide to update (with --todo) or delete (without --todo); omit to add"},
},
Tips: []string{
"Add a todo: `--todo \"...\" --is-done=false`.",
"Update a todo: `--todo-id <id> --todo \"...\" --is-done`.",
"Delete a todo: `--todo-id <id>` (omit --todo).",
"`content` is plain text only; markdown formatting is not supported.",
"Use `lark-cli vc +notes --minute-tokens <token>` to read current todos before writing.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := 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 _, err := resolveMinuteTodoOp(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/todo", validate.EncodePathSegment(runtime.Str("minute-token")))).
Body(map[string]interface{}{
"todo_items": "<todo_items array>",
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := runtime.Str("minute-token")
op, err := resolveMinuteTodoOp(runtime)
if err != nil {
return err
}
path := fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/todo", validate.EncodePathSegment(minuteToken))
body := map[string]interface{}{
"todo_items": []interface{}{op.item},
}
if _, err := runtime.CallAPI(http.MethodPut, path, nil, body); err != nil {
return err
}
// Intentionally omit the todo id from the output: users never see it.
runtime.OutFormat(map[string]interface{}{
"minute_token": minuteToken,
"operation": op.operation,
"updated": true,
}, nil, nil)
return nil
},
}
func resolveMinuteTodoOp(runtime *common.RuntimeContext) (*minuteTodoOp, error) {
todo := strings.TrimSpace(runtime.Str("todo"))
todoID := strings.TrimSpace(runtime.Str("todo-id"))
hasTodo := todo != ""
hasTodoID := todoID != ""
hasIsDone := runtime.Changed("is-done")
item := map[string]interface{}{}
if hasTodoID {
item["todo_id"] = todoID
}
switch {
case hasTodo:
// add or update: content and is_done must appear together
if !hasIsDone {
return nil, output.ErrValidation("--todo requires --is-done")
}
item["content"] = todo
item["is_done"] = runtime.Bool("is-done")
if hasTodoID {
return &minuteTodoOp{operation: "update", item: item}, nil
}
return &minuteTodoOp{operation: "add", item: item}, nil
case hasTodoID:
// delete: only the id is needed; content/is_done are not allowed
if hasIsDone {
return nil, output.ErrValidation("--is-done cannot be used to delete a todo (omit it, and provide only --todo-id)")
}
return &minuteTodoOp{operation: "delete", item: item}, nil
default:
return nil, output.ErrValidation("provide --todo (with --is-done) to add/update, or --todo-id alone to delete")
}
}

View File

@@ -1,94 +0,0 @@
// 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

@@ -1,154 +0,0 @@
// 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,7 +11,7 @@ func Shortcuts() []common.Shortcut {
MinutesSearch,
MinutesDownload,
MinutesUpload,
MinutesUpdate,
MinutesSpeakerReplace,
MinutesSummary,
MinutesTodo,
}
}

View File

@@ -414,9 +414,6 @@ 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,7 +126,6 @@ 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,12 +59,14 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
## 查看完整参数定义
lark-cli schema calendar.events.create
## 创建日程
lark-cli calendar events create \
--params '{"calendar_id":"<CALENDAR_ID>"}' \
--data '{
"summary": "技术分享CLI 架构设计",
lark-cli calendar events create --calendar-id primary --data '{
"summary": "产品评审",
"description": "本周分享主题CLI 架构设计",
"start_time": { "timestamp": "1741586400" },
"end_time": { "timestamp": "1741593600" }
"end_time": { "timestamp": "1741593600" },
"location": { "name": "5F-大会议室" },
"attendee_ability": "can_modify_event",
"reminders": [{ "minutes": 15 }]
}'
# 第二步:添加参会人(使用第一步返回的 calendar_id 和 event_id
@@ -72,7 +74,7 @@ lark-cli calendar events create \
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程
@@ -80,7 +82,8 @@ lark-cli calendar event.attendees create \
lark-cli schema calendar.events.delete
## 删除空日程
lark-cli calendar events delete \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>","need_notification":false}'
--calendar-id <CALENDAR_ID> --event-id <EVENT_ID> \
--params '{"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。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML也支持 Markdown。"
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。当用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是资源类型、URL 路径模式和 token而不是域名。"
description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题docx、sheet、bitable、file、folder、wiki也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base导入为飞书在线云文档docx、sheet、bitable、slides。当用户需要上传或下载文件、整理云空间云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。"
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.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物。遇到这类请求时,应优先使用本 skill,而不是尝试 `ffmpeg``whisper` 等本地转写命令。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
metadata:
requires:
bins: ["lark-cli"]
@@ -98,8 +98,9 @@ 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`
> - 用户说"替换 / 更新这个妙记的总结" → [`minutes +summary`](references/lark-minutes-summary.md)
> - 用户说"新增 / 更新 / 删除这个妙记的某条待办" → [`minutes +todo`](references/lark-minutes-todo.md)
## Shortcuts推荐优先使用
@@ -110,14 +111,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) |
| [`+summary`](references/lark-minutes-summary.md) | Replace the AI summary of a minute |
| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete a single todo item |
- 使用 `+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不支持姓名
- 使用 `+summary` 时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md);妙记端通常可良好展示:一级/二级/三级标题(`#` / `##` / `###`)、加粗(`**text**`)、无序列表(`-` / `*`)、有序列表(`1.`),以及纯文本与换行;不支持的 Markdown 语法会按原始文本展示接口不会因此拒绝Agent 写入时应优先使用可展示子集
- 使用 `+todo` 时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);对单条待办做增删改:新增传 `--todo`(纯文本)+`--is-done`,更新再加 `--todo-id`,删除只传 `--todo-id`。待办 id 通过 `vc +notes` 读取,不向用户展示
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
@@ -141,7 +142,5 @@ 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

@@ -1,50 +0,0 @@
# 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,122 @@
# minutes +summary
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
替换妙记的 AI 总结内容。写操作,会覆盖当前总结。
本 skill 对应 shortcut`lark-cli minutes +summary`(调用 `PUT /open-apis/minutes/v1/minutes/{minute_token}/summary`)。
## 典型触发表达
- "把这条妙记的总结改成……"
- "更新 / 替换妙记的 AI 总结"
- "修正总结内容后写回妙记"
## 命令
```bash
# 直接传入总结内容Markdown 子集)
lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary "**会议结论**\n- 方案 A 通过\n- 下周跟进排期"
# 从文件读取总结内容
lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @summary.md
# 从 stdin 读取
echo "**结论**" | lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @-
# 预览 API 调用
lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @summary.md --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--minute-token <token>` | 是 | 妙记 Token |
| `--summary <text>` | 是 | 替换后的总结内容,支持 `@file` / `@-`stdin |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 核心约束
### 1. 先读后写
替换前建议先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前总结,确认 `minute_token` 与待替换内容无误。
### 2. Markdown 展示说明
接口接受任意总结文本,**不会因 Markdown 格式校验失败而拒绝请求**。妙记客户端通常只能良好渲染以下 Markdown 子集;不支持的语法(如链接、代码块、四级标题等)会**按原始文本展示**(保留 Markdown 标记字符不会渲染成对应样式。Agent 写入时应优先使用可展示语法,避免用户在妙记里看到字面量的 `[链接](url)`、`` `code` `` 等:
| 支持 | 写法 | 示例 |
|------|------|------|
| 纯文本 | 普通段落 | `本次会议讨论了 Q2 预算` |
| 换行 | `\n` 或空行 | 分段落书写 |
| 一级标题 | `# ` + 标题文字 | `# 会议结论` |
| 二级标题 | `## ` + 标题文字 | `## 行动项` |
| 三级标题 | `### ` + 标题文字 | `### 跟进事项` |
| 加粗 | `**文字**` | `**重点结论**` |
| 无序列表 | `- ` 或 `* ` | `- 跟进预算审批` |
| 有序列表 | `1. ` | `1. 确认需求` |
> 标题语法建议:`#` 后保留空格,并优先使用 13 级(`#` / `##` / `###`)。四级及以上(`####`)无法渲染,会以原始文本形式展示。
**不建议使用**(会按原始文本展示):链接、图片、代码块、表格、引用块、斜体、删除线、四级及以上标题等。
合法示例:
```markdown
# 会议结论
## 核心讨论
**方案 A 通过**,下周启动排期。
### 待跟进
- 预算审批
- 排期确认
1. 张三负责预算
2. 李四负责排期
```
### 3. 所需权限
| 身份 | 所需权限 |
|------|---------|
| user | `minutes:minutes:update` |
## 输出结果
```json
{
"minute_token": "obcnxxxxxxxxxxxxxxxxxxxx",
"updated": true
}
```
| 字段 | 说明 |
|------|------|
| `minute_token` | 妙记 Token |
| `updated` | 是否已成功更新 |
## 如何获取 minute_token
| 来源 | 获取方式 |
|------|---------|
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` |
| 妙记搜索 | `lark-cli minutes +search --query "关键词"` |
| 会议产物查询 | `lark-cli vc +notes --minute-tokens <token>` |
## 常见错误与排查
| 错误现象 | 错误码 | 根本原因 | 解决方案 |
|---------|--------|---------|---------|
| 总结展示为原始 Markdown 文本 | — | 总结含链接、四级标题等妙记端无法渲染的语法 | 改用标题(####)、加粗、列表等可展示格式;接口不会因此报错 |
| 参数无效 | — | `minute_token` 缺失或格式错误 | 检查 token 是否完整 |
| 权限不足 | — | 缺少 `minutes:minutes:update` | 运行 `auth login --scope "minutes:minutes:update"` |
## 参考
- [lark-minutes](../SKILL.md) — 妙记全部命令
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -0,0 +1,122 @@
# minutes +todo
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
对妙记中的**单条**待办做新增 / 更新 / 删除。写操作。
本 skill 对应 shortcut`lark-cli minutes +todo`(调用 `PUT /open-apis/minutes/v1/minutes/{minute_token}/todo`)。
## 典型触发表达
- "给这条妙记加一条待办"
- "把某条待办改成……"
- "标记某条待办为已完成 / 取消完成"
- "删除某条待办"
## 命令
```bash
# 新增一条待办(不带 idcontent 与 is_done 成对)
lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --todo "跟进预算审批" --is-done=false
# 更新已有待办(带 id覆盖内容与完成状态
lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --todo-id 1234567890 --todo "整理会议纪要" --is-done
# 删除已有待办(只带 id不带 --todo
lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --todo-id 1234567890
# 预览 API 调用
lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --todo "新待办" --is-done --dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--minute-token <token>` | 是 | 妙记 Token |
| `--todo <text>` | 视操作 | 待办纯文本;新增 / 更新时必填,且必须与 `--is-done` 成对出现;删除时不传 |
| `--is-done` | 视操作 | 完成状态布尔值;传 `--is-done` 表示 `true`,传 `--is-done=false` 表示 `false`;仅在带 `--todo` 时使用 |
| `--todo-id <id>` | 视操作 | 已有待办的 id更新 / 删除时必填,新增时不传 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
## 三种操作的判定
| `--todo-id` | `--todo` | 行为 |
|-------------|----------|------|
| 不传 | 有内容 | **新增**一条待办(需 `--is-done` |
| 传 | 有内容 | **更新** id 对应的待办(需 `--is-done` |
| 传 | 不传 | **删除** id 对应的待办 |
## 核心约束
### 1. 先读后写,待办 id 如何获取
更新 / 删除前先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前待办。返回的每条待办带 `todo_id` 字段,用作 `--todo-id` 的取值。
> 待办 id 仅用于程序内部定位某条待办,不必展示给用户;本命令的输出也不会回显 id。
读取与写入均使用 `is_done` 布尔字段。已删除的待办不会出现在读取结果中。
### 2. 待办内容为纯文本
`content` **不是 Markdown**,请直接传入待办描述文字。
- 不要写 `# 标题``**加粗**``- 列表` 等 Markdown 语法
- 如需多行内容,可直接使用换行;但不会被渲染为 Markdown 格式
### 3. 请求体字段
请求体 `todo_items` 始终只包含**一条**待办:
| CLI | JSON 字段 | 说明 |
|-----|-----------|------|
| `--todo` | `content` | 纯文本待办描述(新增 / 更新必填;删除不传) |
| `--is-done` | `is_done` | 是否已完成(新增 / 更新必填;删除不传) |
| `--todo-id` | `todo_id` | 已有待办 id更新 / 删除必填;新增不传) |
### 4. 所需权限
| 身份 | 所需权限 |
|------|---------|
| user | `minutes:minutes:update` |
## 输出结果
```json
{
"minute_token": "obcnxxxxxxxxxxxxxxxxxxxx",
"operation": "add",
"updated": true
}
```
| 字段 | 说明 |
|------|------|
| `minute_token` | 妙记 Token |
| `operation` | 本次操作类型:`add` / `update` / `delete` |
| `updated` | 是否已成功提交 |
## 如何获取 minute_token
| 来源 | 获取方式 |
|------|---------|
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` |
| 妙记搜索 | `lark-cli minutes +search --query "关键词"` |
| 会议产物查询 | `lark-cli vc +notes --minute-tokens <token>` |
## 常见错误与排查
| 错误现象 | 根本原因 | 解决方案 |
|---------|---------|---------|
| 参数无效 | `minute_token` 缺失 | 检查 token |
| 未指定操作 | 既没传 `--todo` 也没传 `--todo-id` | 新增 / 更新需 `--todo`,删除需 `--todo-id` |
| 缺少 `is_done` | 传了 `--todo` 未传 `--is-done` | `--todo``--is-done` 必须成对出现 |
| 删除时多传了 `--is-done` | 删除只需 `--todo-id` | 删除时不要传 `--todo` / `--is-done` |
| 权限不足 | 缺少 `minutes:minutes:update` | 运行 `auth login --scope "minutes:minutes:update"` |
## 参考
- [lark-minutes](../SKILL.md) — 妙记全部命令
- [minutes +summary](lark-minutes-summary.md) — 替换 AI 总结(不支持的 Markdown 会按原始文本展示,详见该文档)
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

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

View File

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

View File

@@ -13,24 +13,18 @@ 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, both completed and incomplete (fetches up to 20 pages by default)
# Get all my tasks (fetches up to 20 pages by default)
lark-cli task +get-my-tasks
# Pending-only: my incomplete tasks (use this for standup/daily-summary)
# Get my incomplete tasks (fetches up to 20 pages by default)
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,8 +96,7 @@ Meeting (视频会议)
├── Transcript (文字记录)
├── Summary (总结)
├── Todos (待办)
── Chapters (章节)
└── Keywords (推荐关键词)
── Chapters (章节)
```
> **注意**`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。

View File

@@ -91,9 +91,8 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
| 字段 | 说明 |
|------|------|
| `artifacts.summary` | AI 总结JSON 内联) |
| `artifacts.todos` | 待办事项JSON 内联) |
| `artifacts.todos` | 待办事项JSON 内联);每条含 `content``is_done``todo_id``todo_id` 仅供 `minutes +todo` 更新/删除单条待办时使用,不必展示给用户 |
| `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: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -116,3 +116,4 @@ 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 --complete=false [--due-end] ──► 未完成待办列表
{date} ─┬─► calendar +agenda [--start/--end] ──► 日程列表(会议/事件)
└─► task +get-my-tasks [--due-end] ──► 未完成待办列表
AI 汇总(时间转换 + 冲突检测 + 排序)──► 摘要
@@ -54,21 +54,19 @@ lark-cli calendar +agenda --start "2026-03-26T00:00:00+08:00" --end "2026-03-26T
### Step 2: 获取未完成待办
```bash
# 默认 pending 摘要:必须显式过滤未完成任务(最多 20 条)
lark-cli task +get-my-tasks --complete=false
# 默认:返回分配给当前用户的未完成任务(最多 20 条)
lark-cli task +get-my-tasks
# 只看指定日期前到期的未完成任务(推荐用于摘要场景,减少数据量)
lark-cli task +get-my-tasks --complete=false --due-end "2026-03-27T23:59:59+08:00"
# 只看指定日期前到期的(推荐用于摘要场景,减少数据量)
lark-cli task +get-my-tasks --due-end "2026-03-27T23:59:59+08:00"
# 获取全部未完成任务(超过 20 条时)
lark-cli task +get-my-tasks --complete=false --page-all
# 获取全部(超过 20 条时)
lark-cli task +get-my-tasks --page-all
```
> **注意**`+get-my-tasks` 不带 `--complete` 时会**同时返回已完成和未完成任务**,会把已完成任务当成"待办"展示进摘要里。站会/日报这种 pending 汇总场景**必须**显式带上 `--complete=false`,不要省略。
>
> 数据量层面也建议加过滤:
> **注意**不带过滤条件时可能返回大量历史待办(实测 30+ 条、100KB+),容易超出上下文限制。摘要场景建议:
> - 用 `--due-end` 过滤出目标日期前到期的任务
> - 如果也需要无截止日期的任务,可不加 `--due-end`,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办"
> - 如果也需要无截止日期的任务,可不加过滤,但 AI 汇总时只展示**近 30 天内创建的**,其余折叠为"其他 N 项历史待办"
### Step 3: AI 汇总

View File

@@ -45,30 +45,12 @@ 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

@@ -1,40 +0,0 @@
// 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

@@ -1,38 +0,0 @@
// 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)
}