Skip to content

Commit 652e969

Browse files
feat(wiki): add +member-add / +member-remove / +member-list shortcuts (#997)
- +member-add: wrap POST /spaces/{id}/members; --member-type / --member-role enums, optional --need-notification query (omitted entirely when the flag is unset, instead of forcing need_notification=false), my_library resolution under --as user, flattened single-member output - +member-remove: wrap DELETE /spaces/{id}/members/{member_id}; surfaces the required member_type + member_role body the API expects, my_library resolution, fallback to echoing the caller's inputs when the API omits the member echo - +member-list: wrap GET /spaces/{id}/members; reuses the +space-list / +node-list pagination contract (single page by default, --page-all walks every page capped by --page-limit, --page-token resumes a cursor) - All three reject bot identity + my_library upfront with a clear hint and declare the narrowest scope the API accepts (wiki:member:create / wiki:member:update / wiki:member:retrieve) so tokens carrying only the narrow scope are not false-rejected by the exact-string preflight - skill docs: reference pages for the three new shortcuts + SKILL.md shortcuts table; switch the membership flow guidance from raw `wiki members create` to the new +member-add path Change-Id: I158a86aa7f00bb7cecc7a4e99346f3fb151b3c09
1 parent 6cea6c9 commit 652e969

11 files changed

Lines changed: 1547 additions & 18 deletions

File tree

shortcuts/wiki/shortcuts.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,8 @@ func Shortcuts() []common.Shortcut {
1717
WikiNodeCopy,
1818
WikiNodeGet,
1919
WikiNodeDelete,
20+
WikiMemberAdd,
21+
WikiMemberRemove,
22+
WikiMemberList,
2023
}
2124
}

shortcuts/wiki/wiki_member_add.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package wiki
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strings"
10+
11+
"github.com/larksuite/cli/internal/output"
12+
"github.com/larksuite/cli/internal/validate"
13+
"github.com/larksuite/cli/shortcuts/common"
14+
)
15+
16+
// WikiMemberAdd wraps POST /open-apis/wiki/v2/spaces/{space_id}/members. The
17+
// shortcut adds flag ergonomics over the raw API: explicit --member-type and
18+
// --member-role enum hints, optional --need-notification, my_library
19+
// resolution, and a flattened single-member output envelope.
20+
var WikiMemberAdd = common.Shortcut{
21+
Service: "wiki",
22+
Command: "+member-add",
23+
Description: "Add a member to a wiki space",
24+
Risk: "write",
25+
// The API also accepts wiki:wiki, but the framework's preflight does
26+
// exact-string scope matching (see +space-list), so declare the narrowest
27+
// scope so tokens that only carry wiki:member:create aren't false-rejected.
28+
Scopes: []string{"wiki:member:create"},
29+
AuthTypes: []string{"user", "bot"},
30+
Flags: []common.Flag{
31+
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library (user only)", Required: true},
32+
{Name: "member-id", Desc: "member ID; interpretation is decided by --member-type", Required: true},
33+
{Name: "member-type", Desc: "ID type for --member-id", Required: true, Enum: wikiMemberTypes},
34+
{Name: "member-role", Desc: "role granted within the space", Required: true, Enum: wikiMemberRoles},
35+
{Name: "need-notification", Type: "bool", Desc: "send an in-app notification to the new member after the grant"},
36+
},
37+
Tips: []string{
38+
"Use --member-type=email with the user's mailbox if you do not know their open_id.",
39+
"--member-role=admin grants full space administration; pick --member-role=member for collaborator access.",
40+
"--space-id my_library is a per-user alias and is only valid with --as user.",
41+
},
42+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
43+
_, err := readWikiMemberAddSpec(runtime)
44+
return err
45+
},
46+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
47+
spec, err := readWikiMemberAddSpec(runtime)
48+
if err != nil {
49+
return common.NewDryRunAPI().Set("error", err.Error())
50+
}
51+
return buildWikiMemberAddDryRun(spec)
52+
},
53+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
54+
spec, err := readWikiMemberAddSpec(runtime)
55+
if err != nil {
56+
return err
57+
}
58+
59+
spaceID, err := resolveWikiMemberSpaceID(runtime, spec.SpaceID)
60+
if err != nil {
61+
return err
62+
}
63+
64+
fmt.Fprintf(runtime.IO().ErrOut, "Adding wiki space member %s (type=%s, role=%s) to space %s...\n",
65+
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
66+
67+
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
68+
data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody())
69+
if err != nil {
70+
return err
71+
}
72+
73+
out := wikiMemberAddOutput(spaceID, common.GetMap(data, "member"))
74+
// Defensive default: mirror +member-remove and fall back to the caller's
75+
// inputs per-field when the API echoes empty strings or omits member
76+
// fields, so scripts always see what was added.
77+
if common.GetString(out, "member_id") == "" {
78+
out["member_id"] = spec.MemberID
79+
}
80+
if common.GetString(out, "member_type") == "" {
81+
out["member_type"] = spec.MemberType
82+
}
83+
if common.GetString(out, "member_role") == "" {
84+
out["member_role"] = spec.MemberRole
85+
}
86+
fmt.Fprintf(runtime.IO().ErrOut, "Added wiki space member %s\n", common.MaskToken(common.GetString(out, "member_id")))
87+
runtime.Out(out, nil)
88+
return nil
89+
},
90+
}
91+
92+
// wikiMemberAddSpec is the normalized CLI input.
93+
type wikiMemberAddSpec struct {
94+
SpaceID string
95+
MemberID string
96+
MemberType string
97+
MemberRole string
98+
NeedNotification bool
99+
NotificationSet bool
100+
}
101+
102+
// RequestBody builds the JSON body for POST /spaces/{id}/members.
103+
func (spec wikiMemberAddSpec) RequestBody() map[string]interface{} {
104+
return map[string]interface{}{
105+
"member_id": spec.MemberID,
106+
"member_type": spec.MemberType,
107+
"member_role": spec.MemberRole,
108+
}
109+
}
110+
111+
// QueryParams returns nil unless the caller explicitly set --need-notification,
112+
// so the request stays clean when the flag is omitted instead of always
113+
// forcing need_notification=false.
114+
func (spec wikiMemberAddSpec) QueryParams() map[string]interface{} {
115+
if !spec.NotificationSet {
116+
return nil
117+
}
118+
return map[string]interface{}{"need_notification": spec.NeedNotification}
119+
}
120+
121+
func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, error) {
122+
spec := wikiMemberAddSpec{
123+
SpaceID: strings.TrimSpace(runtime.Str("space-id")),
124+
MemberID: strings.TrimSpace(runtime.Str("member-id")),
125+
MemberType: strings.ToLower(strings.TrimSpace(runtime.Str("member-type"))),
126+
MemberRole: strings.ToLower(strings.TrimSpace(runtime.Str("member-role"))),
127+
NeedNotification: runtime.Bool("need-notification"),
128+
NotificationSet: runtime.Cmd.Flags().Changed("need-notification"),
129+
}
130+
if err := validateWikiMemberSpaceID(runtime, spec.SpaceID); err != nil {
131+
return wikiMemberAddSpec{}, err
132+
}
133+
if spec.MemberID == "" {
134+
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
135+
}
136+
// The space-member API rejects opendepartmentid grants under a
137+
// tenant_access_token; surface that as a CLI validation error so callers do
138+
// not waste a network round-trip on a server-side 403. The escape hatch is
139+
// --as user, which is the only identity the API accepts for departments.
140+
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
141+
return wikiMemberAddSpec{}, output.ErrValidation(
142+
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
143+
)
144+
}
145+
// --member-type / --member-role enum membership is enforced by the
146+
// framework's validateEnumFlags (runner.go) before Validate runs, so no
147+
// extra membership check is needed here.
148+
return spec, nil
149+
}
150+
151+
func buildWikiMemberAddDryRun(spec wikiMemberAddSpec) *common.DryRunAPI {
152+
dry := common.NewDryRunAPI()
153+
if spec.SpaceID == wikiMyLibrarySpaceID {
154+
dry.Desc("2-step orchestration: resolve my_library -> add wiki space member").
155+
GET("/open-apis/wiki/v2/spaces/my_library").
156+
Desc("[1] Resolve my_library space ID")
157+
dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", "<resolved_space_id>")).
158+
Desc("[2] Add wiki space member").
159+
Params(spec.QueryParams()).
160+
Body(spec.RequestBody())
161+
return dry
162+
}
163+
return dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spec.SpaceID))).
164+
Params(spec.QueryParams()).
165+
Body(spec.RequestBody())
166+
}
167+
168+
// wikiMemberAddOutput flattens data.member onto a top-level envelope so
169+
// scripts can read member fields without traversing the nested response.
170+
func wikiMemberAddOutput(spaceID string, raw map[string]interface{}) map[string]interface{} {
171+
out := map[string]interface{}{"space_id": spaceID}
172+
for k, v := range wikiMemberRecord(raw) {
173+
out[k] = v
174+
}
175+
return out
176+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package wiki
5+
6+
import (
7+
"fmt"
8+
9+
"github.com/larksuite/cli/internal/output"
10+
"github.com/larksuite/cli/shortcuts/common"
11+
)
12+
13+
// wikiMemberTypes is the set of member_type values the space-member APIs
14+
// accept. Shared by +member-add and +member-remove so the two stay aligned.
15+
var wikiMemberTypes = []string{
16+
"openid", "userid", "email", "unionid", "openchat", "opendepartmentid",
17+
}
18+
19+
// wikiMemberRoles is the set of member_role values the space-member APIs
20+
// accept.
21+
var wikiMemberRoles = []string{"admin", "member"}
22+
23+
// validateWikiMemberSpaceID enforces the two universal rules for the
24+
// space-member shortcuts:
25+
// - --space-id must be non-blank and a valid resource name
26+
// - bot identity may not use the my_library alias (it has no meaning for a
27+
// tenant_access_token; same contract as +node-list / +node-create)
28+
func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error {
29+
if spaceID == "" {
30+
return output.ErrValidation("--space-id is required and cannot be blank")
31+
}
32+
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
33+
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
34+
}
35+
return validateOptionalResourceName(spaceID, "--space-id")
36+
}
37+
38+
// resolveWikiMemberSpaceID transparently expands the my_library alias to the
39+
// caller's real per-user space_id; raw IDs pass through. Mirrors the pattern
40+
// used by +node-list so the three member shortcuts behave the same way.
41+
func resolveWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) (string, error) {
42+
if spaceID != wikiMyLibrarySpaceID {
43+
return spaceID, nil
44+
}
45+
resolved, err := resolveMyLibrarySpaceID(runtime)
46+
if err != nil {
47+
return "", err
48+
}
49+
fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved))
50+
return resolved, nil
51+
}
52+
53+
// wikiMemberRecord parses a /spaces/{id}/members member object into a stable
54+
// flat map. Used by all three shortcuts so they emit the same shape.
55+
func wikiMemberRecord(raw map[string]interface{}) map[string]interface{} {
56+
if raw == nil {
57+
// Callers (wikiMemberAddOutput, member-remove Execute) handle nil via
58+
// for-range or per-field fallback against the caller's input spec.
59+
return nil
60+
}
61+
out := map[string]interface{}{
62+
"member_id": common.GetString(raw, "member_id"),
63+
"member_type": common.GetString(raw, "member_type"),
64+
"member_role": common.GetString(raw, "member_role"),
65+
}
66+
if t := common.GetString(raw, "type"); t != "" {
67+
out["type"] = t
68+
}
69+
return out
70+
}

0 commit comments

Comments
 (0)