Compare commits

...

6 Commits

Author SHA1 Message Date
liangshuo-1
a18504b1f9 chore(release): v1.0.30 (#871)
Change-Id: Iaa769f2ddc98ece7bf36efe821d4eb192f7fc727
2026-05-13 20:11:06 +08:00
shifengjuan-dev
5e0ac02f08 feat(im): add --chat-mode topic to +chat-create (#790)
Adds --chat-mode group|topic to lark-cli im +chat-create so users and AI agents can create 话题群 (topic chats) directly via the CLI. Without this, requests to create a topic chat silently fall back to a normal conversation group. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body.

Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261
2026-05-13 18:03:58 +08:00
aj
b0c9a4d74e fix(auth): support comma-separated --scope in auth login (#764)
`lark-cli auth login --scope "a,b"` previously sent the raw comma-joined
string to the device authorization endpoint, which treats it as a single
malformed scope and fails with:

  device authorization failed: The provided scope list contains invalid
  or malformed scopes.

OAuth 2.0 (RFC 6749 §3.3) requires space-delimited scopes on the wire,
but commas are the more natural separator for users typing on a shell
(quoting whitespace is awkward, especially for AI-agent generated
commands). Accept both: split on commas/whitespace, trim, dedupe, then
re-join with single spaces.

Also adds unit tests covering single, comma, space, mixed, dedupe, and
trailing-separator inputs.

Co-authored-by: aj <2072584+meijing0114@users.noreply.github.com>
2026-05-13 14:27:55 +08:00
JackZhao10086
ddc24fec90 fix(auth): clarify URL handling in auth messages and docs (#856) 2026-05-13 14:09:53 +08:00
liangshuo-1
25454f498b test(update): isolate stamp writes from real ~/.lark-cli/skills.stamp (#858)
Five tests in cmd/update mocked SkillsUpdateOverride to return success
and let runSkillsAndStamp call WriteStamp, but did not isolate
LARKSUITE_CLI_CONFIG_DIR. Each run clobbered the real
~/.lark-cli/skills.stamp with the mock version ("2.0.0" or "1.0.0"),
causing skillscheck to fire a misleading drift notice on every
subsequent lark-cli invocation.

Add t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) at the top of:
  - TestUpdateNpm_JSON
  - TestUpdateNpm_Human
  - TestUpdateForce_JSON
  - TestUpdateDevVersion_JSON
  - TestUpdateWindows_NpmSuccess_JSON

Scope is limited to tests that mock SkillsUpdateOverride to success;
tests that invoke real npx are pre-existing and out of scope here.

Change-Id: I7a78a6c70f276b51333253acc115e0109c01a851
2026-05-13 13:52:22 +08:00
evandance
62ff3d66a6 fix(bind): accept ~/ paths in OpenClaw secret references (#839)
OpenClaw stores secret file paths in user-authored ~/-relative form so
the configuration stays portable across machines. lark-cli config bind
previously rejected these as non-absolute, blocking users until they
rewrote the OpenClaw config with literal absolute paths.

bind now resolves ~ to the OpenClaw home directory (OPENCLAW_HOME if
set, otherwise the OS home) before the path audit runs, mirroring how
OpenClaw itself reads the same field. Cwd-relative paths and other
unsafe locations are still rejected as before.
2026-05-13 12:34:43 +08:00
16 changed files with 867 additions and 18 deletions

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.30] - 2026-05-13
### Features
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
### Bug Fixes
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
- **auth**: Clarify URL handling in auth messages and docs (#856)
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
### Tests
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
## [v1.0.29] - 2026-05-12
### Features
@@ -676,6 +692,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27

View File

@@ -62,7 +62,7 @@ browser. Run it in the background and retrieve the verification URL from its out
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated)")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
available := sortedKnownDomains()
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
@@ -185,7 +185,11 @@ func authLoginRun(opts *LoginOptions) error {
}
}
finalScope := opts.Scope
// Normalize --scope so users can pass either OAuth-standard space-separated
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
// space-delimited scopes in the wire request, so the device authorization
// endpoint rejects raw "a,b" strings as a single malformed scope.
finalScope := normalizeScopeInput(opts.Scope)
// Resolve scopes from domain/permission filters
if len(selectedDomains) > 0 || opts.Recommend {
@@ -232,7 +236,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -532,6 +536,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
return false
}
// normalizeScopeInput accepts a user-supplied --scope value that may use
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
// canonical OAuth 2.0 wire form: a single space-joined string with empties
// trimmed and duplicates removed (first occurrence wins; order preserved).
//
// Examples:
//
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
// "a, b , c" -> "a b c"
// "a b a" -> "a b"
// "" -> ""
func normalizeScopeInput(raw string) string {
if raw == "" {
return ""
}
// Treat both commas and any whitespace as separators.
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return ""
}
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
out = append(out, f)
}
return strings.Join(out, " ")
}
// suggestDomain finds the best "did you mean" match for an unknown domain.
func suggestDomain(input string, known map[string]bool) string {
// Check common cases: prefix match or input is a substring

View File

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询**不要短 timeout 反复重试**——每次重启会作废上一轮的 device code导致用户授权链接失效。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s如不支持长 timeout请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询**不要短 timeout 反复重试**每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout** each restart invalidates the previous device code, so any URL the user already authorized becomes useless.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",

View File

@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
}
}
func TestNormalizeScopeInput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"single", "vc:note:read", "vc:note:read"},
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
{"trim_and_dedup", " a , b , a ", "a b"},
{"trailing_separators", "a,b,,", "a b"},
{"only_separators", " , , ", ""},
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeScopeInput(tc.in); got != tc.want {
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
@@ -879,6 +905,57 @@ func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
}
}
func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("authLoginRun() error = %v", err)
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)
}
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
@@ -917,6 +994,60 @@ func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *
}
}
func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error from cancelled context")
}
dec := json.NewDecoder(strings.NewReader(stdout.String()))
var data map[string]interface{}
if err := dec.Decode(&data); err != nil {
t.Fatalf("Decode(stdout first event) error = %v, stdout=%q", err, stdout.String())
}
hint, _ := data["agent_hint"].(string)
for _, want := range []string{
"timeout >= 600s",
"逐字原样转发 CLI 返回的 URL",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
}
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {

View File

@@ -168,6 +168,11 @@ func TestUpdateManual_Human(t *testing.T) {
}
func TestUpdateNpm_JSON(t *testing.T) {
// Isolate config dir: this test mocks fetchLatest="2.0.0" and lets
// runSkillsAndStamp → WriteStamp succeed, which without isolation would
// clobber the real ~/.lark-cli/skills.stamp with "2.0.0".
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -195,6 +200,9 @@ func TestUpdateNpm_JSON(t *testing.T) {
}
func TestUpdateNpm_Human(t *testing.T) {
// Same isolation as TestUpdateNpm_JSON — see comment there.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -222,6 +230,9 @@ func TestUpdateNpm_Human(t *testing.T) {
}
func TestUpdateForce_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--force", "--json"})
@@ -312,6 +323,9 @@ func TestUpdateInvalidVersion_JSON(t *testing.T) {
}
func TestUpdateDevVersion_JSON(t *testing.T) {
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -629,6 +643,9 @@ func TestPermissionHint(t *testing.T) {
func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) {
// With the rename trick, Windows npm installs can now auto-update.
// Same stamp-isolation rationale as TestUpdateNpm_JSON.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})

View File

@@ -65,7 +65,11 @@ func AssertSecurePath(params AuditParams) (string, error) {
}
// requireAbsolutePath rejects relative paths; relative paths would depend on
// the process cwd and defeat the point of a static audit.
// the process cwd and defeat the point of a static audit. Shell-style
// shortcuts like `~` are home-relative, not cwd-relative — they are an
// orthogonal concern and the audit is intentionally Go-stdlib strict here.
// Callers that accept user-authored config (e.g. resolveFileRef) must
// pre-resolve any such shortcuts before passing the path in.
func requireAbsolutePath(target, label string) error {
if !filepath.IsAbs(target) {
return fmt.Errorf("%s: path must be absolute, got %q", label, target)

View File

@@ -23,9 +23,19 @@ func resolveFileRef(ref *SecretRef, pc *ProviderConfig) (string, error) {
return "", fmt.Errorf("file provider path is empty")
}
// OpenClaw preserves user-authored `~/...` paths verbatim on disk for
// portability and resolves them at read time. lark-cli reads the file
// raw, so we mirror that resolution here before the audit — otherwise
// an unambiguous home-relative path would be rejected by
// requireAbsolutePath, which is meant to guard against cwd-relative
// paths (a different concern). expandTildePath honours OPENCLAW_HOME so
// a tilde inside an OPENCLAW_HOME-overridden config resolves to the
// same absolute path OpenClaw itself would have used.
targetPath := expandTildePath(pc.Path)
// Security audit on file path
securePath, err := AssertSecurePath(AuditParams{
TargetPath: pc.Path,
TargetPath: targetPath,
Label: "secrets.providers file path",
TrustedDirs: pc.TrustedDirs,
AllowInsecurePath: pc.AllowInsecurePath,

View File

@@ -6,6 +6,7 @@ package binding
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -230,3 +231,88 @@ func TestResolveFileRef_ExceedsMaxBytes(t *testing.T) {
t.Errorf("error = %q, want %q", err.Error(), want)
}
}
// TestResolveFileRef_TildePath_SingleValue is the end-to-end smoke test
// for the fix: a singleValue file provider with a ~/-relative path
// resolves correctly through resolveFileRef. Before this PR the audit
// would reject the path as "must be absolute".
func TestResolveFileRef_TildePath_SingleValue(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secret.txt")
if err := os.WriteFile(p, []byte("tilde_secret\n"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "~/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "tilde_secret" {
t.Errorf("got %q, want %q", got, "tilde_secret")
}
}
// TestResolveFileRef_RelativePath_StillRejected guards the absolute-path
// audit: cwd-relative input must still be rejected even though tilde was
// loosened. Catches regressions if expandTildePath is ever widened to
// also expand "./..." (which would weaken the audit's invariant).
func TestResolveFileRef_RelativePath_StillRejected(t *testing.T) {
ref := &SecretRef{Source: "file", ID: SingleValueFileRefID}
pc := &ProviderConfig{
Source: "file",
Path: "relative/secret.txt",
Mode: "singleValue",
AllowInsecurePath: true,
}
_, err := resolveFileRef(ref, pc)
if err == nil {
t.Fatal("expected error for relative path, got nil")
}
wantSub := "path must be absolute"
if !strings.Contains(err.Error(), wantSub) {
t.Errorf("error = %q, want substring %q", err.Error(), wantSub)
}
}
// TestResolveFileRef_TildePath_JSONMode verifies the tilde-expansion
// path works for json mode (where ref id is a JSON pointer) as well as
// singleValue mode — the mechanism is mode-agnostic.
func TestResolveFileRef_TildePath_JSONMode(t *testing.T) {
dir := t.TempDir()
setFakeOSHome(t, dir)
t.Setenv("OPENCLAW_HOME", "")
p := filepath.Join(dir, "secrets.json")
content := `{"providers":{"feishu":{"key":"json_via_tilde"}}}`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
ref := &SecretRef{Source: "file", ID: "/providers/feishu/key"}
pc := &ProviderConfig{
Source: "file",
Path: "~/secrets.json",
Mode: "json",
AllowInsecurePath: true,
}
got, err := resolveFileRef(ref, pc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "json_via_tilde" {
t.Errorf("got %q, want %q", got, "json_via_tilde")
}
}

180
internal/binding/tilde.go Normal file
View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
// hasTildePrefix reports whether s begins with `~` followed by end-of-string,
// `/`, or `\` — the form OpenClaw treats as home-relative.
func hasTildePrefix(s string) bool {
if s == "" || s[0] != '~' {
return false
}
if len(s) == 1 {
return true
}
return s[1] == '/' || s[1] == '\\'
}
// joinTildeSuffix expands a tilde-prefixed string against a resolved home
// directory. Replaces only the leading `~` so the original separator
// (forward or back slash) and suffix bytes are kept verbatim, matching
// OpenClaw's `input.replace(/^~(?=$|[\\/])/, home)` semantics rather than
// going through filepath.Join (which would silently drop a literal `\` on
// POSIX). filepath.Clean is applied so `..` and duplicate separators are
// collapsed in the same way Node's path.resolve does on each platform.
//
// Caller must ensure hasTildePrefix(s) is true and home is non-empty.
func joinTildeSuffix(s, home string) string {
if len(s) == 1 {
return home
}
return filepath.Clean(home + s[1:])
}
// normalizeSentinel applies OpenClaw's normalize() helper to a single
// string: trims whitespace and treats the JS-flavoured literals
// "undefined" / "null" (along with empty/whitespace-only) as unset.
func normalizeSentinel(v string) string {
v = strings.TrimSpace(v)
if v == "undefined" || v == "null" {
return ""
}
return v
}
// osHome returns the OS-level home directory by walking OpenClaw's
// resolution chain: HOME → USERPROFILE → OS user database (getpwuid on
// Unix / user32 on Windows, via os/user.Current). Each candidate is
// passed through normalizeSentinel so sentinel literals and blank
// strings fall through.
//
// Matches OpenClaw's resolveRawOsHomeDir env chain so the same tilde
// resolves against the same home under mixed shell environments and
// accidentally-stringified env values. Go's stdlib os.UserHomeDir on
// Unix only re-reads HOME and gives up; Node's os.homedir() still
// returns the account home via the user database, so the explicit
// user.Current() step is what keeps OpenClaw-authored `~/...` working
// in HOME-unset shells.
//
// Deliberate hybrid contract — neither a strict mirror of OpenClaw
// nor a strict reject-on-missing:
//
// - OpenClaw's final fallback is cwd (via resolveRequiredHomeDir →
// process.cwd()). We don't do that because requireAbsolutePath
// exists precisely to reject cwd-dependent paths; routing
// `~/secret` through cwd would defeat the audit invariant.
//
// - We still go through user.Current() before giving up, even when
// HOME is a sentinel literal ("undefined" / "null") and
// USERPROFILE is unset. At that point OpenClaw would land on cwd,
// and a strict implementation would reject; user.Current() lands
// on the account home instead — cwd-independent and user-bound,
// so it satisfies the audit's safety goal while still letting
// ~/-authored configs resolve in a malformed-env shell.
//
// - Only returns "" when the env chain AND user.Current() are all
// unresolvable, at which point the caller surfaces a clean
// "path must be absolute" error from the audit.
func osHome() string {
if v := normalizeSentinel(os.Getenv("HOME")); v != "" {
return v
}
if v := normalizeSentinel(os.Getenv("USERPROFILE")); v != "" {
return v
}
if u, err := user.Current(); err == nil {
return normalizeSentinel(u.HomeDir)
}
return ""
}
// explicitOpenClawHome reads OPENCLAW_HOME with OpenClaw's normalize()
// semantics applied.
func explicitOpenClawHome() string {
return normalizeSentinel(os.Getenv("OPENCLAW_HOME"))
}
// absolutize returns p as an absolute path, resolving against the process
// cwd when p is relative. Returns "" when the cwd cannot be resolved.
// Wraps filepath.Abs semantics via vfs.Getwd because forbidigo bans
// filepath.Abs inside internal/ packages.
func absolutize(p string) string {
if p == "" {
return ""
}
if filepath.IsAbs(p) {
return filepath.Clean(p)
}
wd, err := vfs.Getwd()
if err != nil {
return ""
}
return filepath.Join(wd, p)
}
// openClawHome returns the home directory used to resolve `~`-relative paths
// authored against OpenClaw's config. Closely mirrors OpenClaw's
// home-resolution semantics so the same tilde resolves to the same
// absolute path here as inside OpenClaw runtime under all normal
// conditions.
//
// Resolution order:
// 1. OPENCLAW_HOME env var, when set (sentinel-normalised).
// 2. If OPENCLAW_HOME itself has a tilde prefix, expand it against the OS
// home (see osHome); the result is empty when the OS home is
// unresolvable.
// 3. Otherwise fall back to the OS home.
//
// The returned path is absolute (relative OPENCLAW_HOME values are
// absolutised against the process cwd, matching Node path.resolve in
// OpenClaw's pipeline).
//
// Returns "" when no home can be resolved. This is a deliberate
// divergence from OpenClaw, whose read pipeline would fall back to
// cwd via resolveRequiredHomeDir — see osHome for the rationale.
func openClawHome() string {
raw := explicitOpenClawHome()
switch {
case raw == "":
raw = osHome()
case hasTildePrefix(raw):
h := osHome()
if h == "" {
return ""
}
raw = joinTildeSuffix(raw, h)
}
return absolutize(raw)
}
// expandTildePath resolves a leading `~` or `~/...` prefix to OpenClaw's
// effective home directory (see openClawHome).
//
// Returns the input unchanged when it lacks a tilde prefix or when
// openClawHome cannot resolve a home directory. The latter case is a
// deliberate divergence from OpenClaw, whose read pipeline falls back
// to cwd — see osHome. Surfacing a "path must be absolute" error from
// the audit is preferable to silently routing a user-authored
// `~/secret` through cwd resolution.
//
// `~user` shell-style expansion is intentionally not supported (OpenClaw
// does not support it either).
func expandTildePath(p string) string {
if !hasTildePrefix(p) {
return p
}
home := openClawHome()
if home == "" {
return p
}
return joinTildeSuffix(p, home)
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package binding
import (
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"testing"
)
// setFakeOSHome controls osHome's env-chain inputs (HOME and USERPROFILE)
// in one call so tests stay deterministic across platforms. osHome reads
// HOME first, then USERPROFILE, then user.Current(); setting only one of
// the two leaves the test sensitive to whichever the runner happens to
// have populated. Passing dir == "" disables both env entries so tests
// can exercise the user.Current() fallback or no-home edge cases.
func setFakeOSHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}
// isolateRuntimeWrites parks the process cwd in a fresh TempDir for the
// test's duration. Tests that set HOME to a sentinel literal trigger Go
// runtime side effects — most visibly the telemetry subsystem, which
// calls os.UserConfigDir() (= "$HOME/Library/Application Support" on
// darwin) and happily writes through a relative result like
// "undefined/Library/...". Without isolation those files land in the
// package or repo dir and get accidentally staged. Chdir'ing into a
// TempDir routes the noise into a path testing.T auto-cleans.
func isolateRuntimeWrites(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(orig)
})
}
// TestOpenClawHome covers the openClawHome resolution table: empty /
// sentinel OPENCLAW_HOME falls back to the OS home, explicit absolute
// values are used verbatim (with whitespace trimmed), and tilde-prefixed
// values recurse through the OS home.
func TestOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
explicit := t.TempDir()
setFakeOSHome(t, homeDir)
tests := []struct {
name string
openclawEnv string
want string
}{
{"unset falls back to OS home", "", homeDir},
{"undefined literal treated as unset", "undefined", homeDir},
{"null literal treated as unset", "null", homeDir},
{"whitespace-only treated as unset", " ", homeDir},
{"explicit absolute path used verbatim", explicit, explicit},
{"explicit absolute path is trimmed", " " + explicit + " ", explicit},
{"bare tilde resolves to OS home", "~", homeDir},
{"tilde-prefixed value recurses through OS home", "~/custom", filepath.Join(homeDir, "custom")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("OPENCLAW_HOME", tc.openclawEnv)
got := openClawHome()
if got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_RelativeIsAbsolutized confirms a relative
// OPENCLAW_HOME is resolved against the process cwd, mirroring Node's
// path.resolve behaviour in OpenClaw.
func TestOpenClawHome_RelativeIsAbsolutized(t *testing.T) {
t.Setenv("OPENCLAW_HOME", filepath.FromSlash("relative/dir"))
got := openClawHome()
if !filepath.IsAbs(got) {
t.Fatalf("openClawHome() = %q, want absolute path", got)
}
wantSuffix := filepath.FromSlash("relative/dir")
if !strings.HasSuffix(got, wantSuffix) {
t.Errorf("openClawHome() = %q, want suffix %q", got, wantSuffix)
}
}
// TestOpenClawHome_FallsBackToUserDatabase pins osHome's final fallback
// to the OS user database when HOME and USERPROFILE are both unset,
// matching Node's os.homedir() (which uses getpwuid). Cwd-independent
// and user-bound, so it does not conflict with the "no cwd fallback"
// rule documented on osHome.
func TestOpenClawHome_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home from user.Current)", got, u.HomeDir)
}
}
// TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback pins that
// a tilde-form OPENCLAW_HOME ("~/custom") expands against the
// user-database fallback when HOME and USERPROFILE are both unset.
// Without the user.Current() step in osHome this would have failed
// (returning "") and dropped the bind back to the audit's
// "path must be absolute" error.
func TestOpenClawHome_TildeOpenClawHomeUsesUserDatabaseFallback(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "~/custom")
got := openClawHome()
want := filepath.Join(u.HomeDir, "custom")
if got != want {
t.Errorf("openClawHome() = %q, want %q", got, want)
}
}
// TestExpandTildePath covers the full input grid for expandTildePath:
// bare tilde, tilde-slash, tilde + suffix, nested suffix, plain absolute
// and relative literals, and the intentionally-unchanged forms (~user,
// ~foo) that OpenClaw does not expand either.
func TestExpandTildePath(t *testing.T) {
fakeHome := t.TempDir()
absFixture := filepath.Join(fakeHome, "abs.json")
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"bare tilde", "~", fakeHome},
{"tilde slash", "~/", fakeHome},
{"tilde with file", "~/secret.json", filepath.Join(fakeHome, "secret.json")},
{"tilde with nested path", "~/.openclaw/secret.json", filepath.Join(fakeHome, ".openclaw/secret.json")},
{"absolute unchanged", absFixture, absFixture},
{"relative unchanged", "foo/bar", "foo/bar"},
{"dot relative unchanged", "../foo", "../foo"},
{"tilde user form unchanged", "~root/foo", "~root/foo"},
{"tilde without separator unchanged", "~foo", "~foo"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := expandTildePath(tc.in)
if got != tc.want {
t.Errorf("expandTildePath(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestExpandTildePath_RespectsOpenClawHome verifies that with
// OPENCLAW_HOME set, tilde expansion uses that custom home rather than
// the OS home — the integration-level invariant that closes the
// internal inconsistency CodeX's first review flagged.
func TestExpandTildePath_RespectsOpenClawHome(t *testing.T) {
homeDir := t.TempDir()
clawHome := t.TempDir()
setFakeOSHome(t, homeDir)
t.Setenv("OPENCLAW_HOME", clawHome)
got := expandTildePath("~/secret.json")
want := filepath.Join(clawHome, "secret.json")
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (should use OPENCLAW_HOME)", "~/secret.json", got, want)
}
if got == filepath.Join(homeDir, "secret.json") {
t.Errorf("expandTildePath unexpectedly used OS home %q instead of OPENCLAW_HOME %q", homeDir, clawHome)
}
}
// TestExpandTildePath_FallsBackToUserDatabase is the end-to-end
// equivalent of TestOpenClawHome_FallsBackToUserDatabase: with HOME and
// USERPROFILE both unset, expandTildePath still resolves `~/foo` via
// osHome's user.Current() step. Matches Node os.homedir() and keeps
// OpenClaw-authored configs working in minimal-env shells.
func TestExpandTildePath_FallsBackToUserDatabase(t *testing.T) {
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
setFakeOSHome(t, "")
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath("~/foo")
want := filepath.Join(u.HomeDir, "foo")
if got != want {
t.Errorf("expandTildePath(~/foo) = %q, want %q", got, want)
}
}
// TestOpenClawHome_OSHomeNormalization pins OpenClaw's sentinel
// normalisation on the env chain: the literals "undefined" / "null" /
// blank-or-whitespace are all treated as unset, so a JS-flavoured
// accidentally-stringified env value (e.g. `HOME=undefined` from a
// shell wrapper) doesn't end up as a literal directory component when
// the user authored `~/secret`. Combined with the user.Current()
// fallback further down (see TestOpenClawHome_FallsBackToUserDatabase),
// the contract is: a malformed HOME falls through to USERPROFILE first,
// and only if that's also unset/sentinel do we go to the user database.
func TestOpenClawHome_OSHomeNormalization(t *testing.T) {
isolateRuntimeWrites(t)
userProfileDir := t.TempDir()
homeWinsDir := t.TempDir()
tests := []struct {
name string
home string
userProfile string
want string
}{
{"HOME=undefined falls through to USERPROFILE", "undefined", userProfileDir, userProfileDir},
{"HOME=null falls through to USERPROFILE", "null", userProfileDir, userProfileDir},
{"HOME=whitespace falls through to USERPROFILE", " ", userProfileDir, userProfileDir},
{"HOME wins over USERPROFILE when both are valid", homeWinsDir, userProfileDir, homeWinsDir},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", tc.home)
t.Setenv("USERPROFILE", tc.userProfile)
t.Setenv("OPENCLAW_HOME", "")
if got := openClawHome(); got != tc.want {
t.Errorf("openClawHome() = %q, want %q", got, tc.want)
}
})
}
}
// TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd pins the
// deliberate hybrid documented on osHome: with HOME a sentinel literal
// and USERPROFILE unset, OpenClaw would fall back to process.cwd();
// this implementation falls to the OS user database instead. The
// account home is both safer (cwd-independent) and more useful (it is
// where the user originally authored `~/...` against), so we prefer it
// over either OpenClaw's cwd fallback or a strict reject.
func TestOpenClawHome_SentinelHOMEFallsToUserDatabaseNotCwd(t *testing.T) {
isolateRuntimeWrites(t)
u, err := user.Current()
if err != nil || u.HomeDir == "" {
t.Skip("os/user.Current() unavailable on this runner")
}
t.Setenv("HOME", "undefined")
t.Setenv("USERPROFILE", "")
t.Setenv("OPENCLAW_HOME", "")
got := openClawHome()
if got != u.HomeDir {
t.Errorf("openClawHome() = %q, want %q (account home, not cwd)", got, u.HomeDir)
}
}
// TestExpandTildePath_BackslashPreservedOnPOSIX pins that `~\secret.json`
// expands by replacing only the `~` byte, leaving the backslash literally
// as part of the filename — matching OpenClaw's regex-replace semantics
// (`/^~(?=$|[\\/])/`) rather than going through filepath.Join (which would
// drop the backslash on POSIX). On Windows backslash is a real separator,
// so the literal-byte invariant doesn't apply.
func TestExpandTildePath_BackslashPreservedOnPOSIX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("backslash is a path separator on Windows; invariant only applies on POSIX")
}
fakeHome := t.TempDir()
setFakeOSHome(t, fakeHome)
t.Setenv("OPENCLAW_HOME", "")
got := expandTildePath(`~\secret.json`)
want := fakeHome + `\secret.json`
if got != want {
t.Errorf("expandTildePath(%q) = %q, want %q (backslash should be preserved as filename byte)", `~\secret.json`, got, want)
}
}

View File

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

View File

@@ -15,6 +15,7 @@ import (
"github.com/spf13/cobra"
)
// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error.
func mustMarshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
@@ -25,6 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
return string(b)
}
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -55,6 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -86,6 +93,8 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st
return &common.RuntimeContext{Cmd: cmd}
}
// TestBuildCreateChatBody verifies the request body assembled when every
// flag is populated, including the default chat_mode="group".
func TestBuildCreateChatBody(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
@@ -94,11 +103,13 @@ func TestBuildCreateChatBody(t *testing.T) {
"users": "ou_1, ou_2",
"bots": "cli_1, cli_2",
"owner": "ou_owner",
"chat-mode": "group",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "group",
"name": "Team Chat",
"description": "daily sync",
"user_id_list": []string{
@@ -116,6 +127,43 @@ func TestBuildCreateChatBody(t *testing.T) {
}
}
// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces
// chat_mode="topic" in the request body, the topic-chat creation path.
func TestBuildCreateChatBody_TopicMode(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Topic Group",
"chat-mode": "topic",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "topic",
"name": "Topic Group",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
}
}
// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback:
// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty
// values), but buildCreateChatBody must still emit chat_mode="group" rather
// than an empty string with unspecified server semantics.
func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Fallback Test",
"chat-mode": "",
}, nil)
got := buildCreateChatBody(runtime)
if got["chat_mode"] != "group" {
t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"])
}
}
// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17]
func TestSplitMembers(t *testing.T) {
got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ")
@@ -591,10 +639,12 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
})
}
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"type", "name", "users", "owner"} {
for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().Bool("set-bot-manager", false, "")
@@ -604,9 +654,10 @@ func TestShortcutDryRunShapes(t *testing.T) {
_ = cmd.Flags().Set("users", "ou_1,ou_2")
_ = cmd.Flags().Set("owner", "ou_owner")
_ = cmd.Flags().Set("set-bot-manager", "true")
_ = cmd.Flags().Set("chat-mode", "group")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot")
got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) {
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) {
t.Fatalf("ImChatCreate.DryRun() = %s", got)
}
})

View File

@@ -16,10 +16,14 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImChatCreate is the +chat-create shortcut: creates a group chat or topic
// chat via POST /open-apis/im/v1/chats. Supports user and bot identities;
// --chat-mode selects group (default) or topic; --type selects private
// (default) or public; --users/--bots invite members at creation.
var ImChatCreate = common.Shortcut{
Service: "im",
Command: "+chat-create",
Description: "Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager",
Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager",
Risk: "write",
UserScopes: []string{"im:chat:create_by_user"},
BotScopes: []string{"im:chat:create"},
@@ -32,6 +36,7 @@ var ImChatCreate = common.Shortcut{
{Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"},
{Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"},
{Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}},
{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -141,9 +146,18 @@ var ImChatCreate = common.Shortcut{
},
}
// buildCreateChatBody assembles the POST /open-apis/im/v1/chats request
// body. chat_mode is always emitted; an empty value (which can slip past
// validateEnumFlags, since that helper skips empty strings) is pinned to
// "group" so the wire never carries an unspecified chat_mode value.
func buildCreateChatBody(runtime *common.RuntimeContext) map[string]interface{} {
chatMode := runtime.Str("chat-mode")
if chatMode == "" {
chatMode = "group"
}
body := map[string]interface{}{
"chat_type": runtime.Str("type"),
"chat_mode": chatMode,
}
if name := runtime.Str("name"); name != "" {
body["name"] = name

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -68,7 +68,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |

View File

@@ -2,7 +2,7 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, and chat type (private/public).
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, chat type (private/public), and group mode. Set `--chat-mode topic` to create a topic chat.
This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `POST /open-apis/im/v1/chats`).
@@ -18,6 +18,9 @@ lark-cli im +chat-create --name "My Group"
# Create a public group (name is required and must be at least 2 characters)
lark-cli im +chat-create --name "Public Group" --type public
# Create a topic chat
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
# Specify the group owner
lark-cli im +chat-create --name "My Group" --owner ou_xxx
@@ -55,12 +58,15 @@ lark-cli im +chat-create --name "My Group" --dry-run
| `--users <ids>` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids |
| `--bots <ids>` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs |
| `--owner <open_id>` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) |
| `--type <type>` | No | `private` (default) or `public` | Group type |
| `--type <type>` | No | `private` (default) or `public` | Group type. Default to `private`; pass `public` only when the user explicitly asks for a discoverable/public group. |
| `--chat-mode <mode>` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`). When the user asks for a topic chat, pass `topic` explicitly — do not rely on the default. |
| `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) |
| `--format json` | No | - | Output as JSON |
| `--as <identity>` | No | `bot` or `user` | Identity type |
| `--dry-run` | No | - | Preview the request without executing it |
> **`--chat-mode topic` vs "normal group with topic-message mode"**: `--chat-mode topic` here creates a 话题群 — the entire group is a topic chat. This is different from "normal group (`chat_mode=group`) + topic-message mode (`group_message_type=thread`)". This CLI exposes only `chat_mode`; `group_message_type` is intentionally not surfaced.
## AI Usage Guidance
### When using `--as bot`

View File

@@ -12,7 +12,9 @@ description: "飞书/Lark CLI 共享基础:应用配置初始化、认证登
首次使用需运行 `lark-cli config init` 完成应用配置。
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户
**URL 转发规则**:当命令输出 `verification_url``verification_uri_complete``console_url` 等 URL 字段时,必须将 URL exactly as returned by the CLI 转发给用户,并把它视为不可修改的 opaque string不要做 URL encode/decode不要补 `%20`、空格或标点,不要重新拼接 query不要改写成 Markdown link text建议用只包含原始 URL 的代码块单独输出。
```bash
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
@@ -51,7 +53,7 @@ lark-cli config init --new
#### Bot 身份(`--as bot`
将错误中的 `console_url` 提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`
#### User 身份(`--as user`
@@ -64,7 +66,7 @@ lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推
#### Agent 代理发起认证(推荐)
当你作为 AI agent 需要帮用户完成认证时使用background方式 执行以下命令发起授权流程, 并将授权链接发给用户:
当你作为 AI agent 需要帮用户完成认证时使用background方式 执行以下命令发起授权流程, 并将授权链接原样发给用户:
```bash
# 发起授权(阻塞直到用户授权完成或过期)