diff --git a/README.md b/README.md index a560e67..6b76e7f 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ notion-cli page create --title "T" --content "Body text" notion-cli page create --title "T" --parent notion-cli page archive # Archive a page via the official API +notion-cli page move --parent "Engineering" # Move under another page +notion-cli page move --parent-db # Move into a database +notion-cli page duplicate # Copy a page with children and permissions +notion-cli page duplicate --json # Output the copy as JSON + # Upload a markdown file as a new page notion-cli page upload ./document.md # Title from # heading or filename notion-cli page upload ./document.md --title "Custom Title" # Explicit title @@ -160,6 +165,11 @@ The comment commands accept a page URL, ID, or name. `comment list` includes bot ### Other ```bash +notion-cli user list # List workspace users (paginated) +notion-cli user list --query alice # Filter by name or email +notion-cli user list --limit 20 # Cap the number of users returned +notion-cli user list --json # Output users as JSON +notion-cli user me # Show the user the token authenticates as notion-cli tools # List available MCP tools notion-cli tools --json # Output tools as JSON notion-cli version # Show version diff --git a/cmd/page.go b/cmd/page.go index 2dec616..37ca35f 100644 --- a/cmd/page.go +++ b/cmd/page.go @@ -15,13 +15,15 @@ import ( ) type PageCmd struct { - List PageListCmd `cmd:"" help:"List pages"` - View PageViewCmd `cmd:"" help:"View a page"` - Create PageCreateCmd `cmd:"" help:"Create a page"` - Upload PageUploadCmd `cmd:"" help:"Upload a markdown file as a page"` - Sync PageSyncCmd `cmd:"" help:"Sync a markdown file to a page (create or update)"` - Edit PageEditCmd `cmd:"" help:"Edit a page"` - Archive PageArchiveCmd `cmd:"" help:"Archive a page via the official API"` + List PageListCmd `cmd:"" help:"List pages"` + View PageViewCmd `cmd:"" help:"View a page"` + Create PageCreateCmd `cmd:"" help:"Create a page"` + Upload PageUploadCmd `cmd:"" help:"Upload a markdown file as a page"` + Sync PageSyncCmd `cmd:"" help:"Sync a markdown file to a page (create or update)"` + Edit PageEditCmd `cmd:"" help:"Edit a page"` + Archive PageArchiveCmd `cmd:"" help:"Archive a page via the official API"` + Move PageMoveCmd `cmd:"" help:"Move a page under a new parent"` + Duplicate PageDuplicateCmd `cmd:"" help:"Duplicate a page with its children and permissions"` } var loadPageViewCommentsFn = loadPageViewComments @@ -829,3 +831,121 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string, skipL } return nil } + +type PageMoveCmd struct { + Page string `arg:"" help:"Page URL, name, or ID"` + Parent string `help:"New parent page URL, name, or ID" short:"p"` + ParentDB string `help:"New parent database URL, name, or ID" name:"parent-db" short:"d"` +} + +func (c *PageMoveCmd) Run(ctx *Context) error { + return runPageMove(ctx, c.Page, c.Parent, c.ParentDB) +} + +func runPageMove(ctx *Context, page, parent, parentDB string) error { + if (parent == "") == (parentDB == "") { + err := &output.UserError{Message: "specify exactly one of --parent or --parent-db"} + output.PrintError(err) + return err + } + + client, err := cli.RequireClient() + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + bgCtx := context.Background() + + pageID, err := cli.ResolvePageID(bgCtx, client, page) + if err != nil { + output.PrintError(err) + return err + } + + req := mcp.MovePageRequest{PageID: pageID} + if parent != "" { + parentID, err := cli.ResolvePageID(bgCtx, client, parent) + if err != nil { + output.PrintError(err) + return err + } + req.ParentPageID = parentID + } else { + databaseID, err := cli.ResolveDatabaseID(bgCtx, client, parentDB) + if err != nil { + output.PrintError(err) + return err + } + dataSourceID, err := client.ResolveDataSourceID(bgCtx, databaseID) + if err != nil { + output.PrintError(err) + return err + } + req.ParentDatabaseID = dataSourceID + } + + if err := client.MovePage(bgCtx, req); err != nil { + output.PrintError(err) + return err + } + + output.PrintSuccess("Page moved") + return nil +} + +type PageDuplicateCmd struct { + Page string `arg:"" help:"Page URL, name, or ID"` + JSON bool `help:"Output as JSON" short:"j"` +} + +func (c *PageDuplicateCmd) Run(ctx *Context) error { + ctx.JSON = c.JSON + return runPageDuplicate(ctx, c.Page) +} + +func runPageDuplicate(ctx *Context, page string) error { + client, err := cli.RequireClient() + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + bgCtx := context.Background() + + pageID, err := cli.ResolvePageID(bgCtx, client, page) + if err != nil { + output.PrintError(err) + return err + } + + resp, err := client.DuplicatePage(bgCtx, pageID) + if err != nil { + output.PrintError(err) + return err + } + + if ctx.JSON { + return output.PrintPage(duplicatedPage(resp), true) + } + + if resp.URL != "" { + output.PrintSuccess("Page duplicated: " + resp.URL) + } else { + output.PrintSuccess("Page duplicated") + } + return nil +} + +// duplicatedPage describes the copy for JSON output. The MCP server may answer +// with prose carrying only the URL, so the ID is recovered from it to keep the +// copy addressable by the next command. +func duplicatedPage(resp *mcp.DuplicatePageResponse) output.Page { + page := output.Page{ID: resp.ID, URL: resp.URL} + if page.ID == "" && page.URL != "" { + if id, ok := cli.ExtractNotionUUID(page.URL); ok { + page.ID = id + } + } + return page +} diff --git a/cmd/page_move_test.go b/cmd/page_move_test.go new file mode 100644 index 0000000..7e12e6e --- /dev/null +++ b/cmd/page_move_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/lox/notion-cli/internal/mcp" +) + +func TestRunPageMoveRequiresExactlyOneParent(t *testing.T) { + err := runPageMove(&Context{}, "My Page", "", "") + if err == nil { + t.Fatal("expected error when no parent is given") + } + if !strings.Contains(err.Error(), "--parent") { + t.Fatalf("error = %q", err.Error()) + } + + err = runPageMove(&Context{}, "My Page", "Parent Page", "Some DB") + if err == nil { + t.Fatal("expected error when both parents are given") + } + if !strings.Contains(err.Error(), "--parent") { + t.Fatalf("error = %q", err.Error()) + } +} + +func TestDuplicatedPageRecoversIDFromURL(t *testing.T) { + page := duplicatedPage(&mcp.DuplicatePageResponse{ + URL: "https://www.notion.so/workspace/Copy-12345678abcdef1234567890abcdef12", + }) + + if page.ID != "12345678-abcd-ef12-3456-7890abcdef12" { + t.Fatalf("id = %q", page.ID) + } +} + +func TestDuplicatedPageKeepsServerID(t *testing.T) { + page := duplicatedPage(&mcp.DuplicatePageResponse{ID: "page-2", URL: "https://www.notion.so/page-2"}) + + if page.ID != "page-2" { + t.Fatalf("id = %q", page.ID) + } +} diff --git a/cmd/root.go b/cmd/root.go index 3e63ade..fb83724 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ type CLI struct { Search SearchCmd `cmd:"" help:"Search Notion"` DB DBCmd `cmd:"" name:"db" help:"Database commands"` Comment CommentCmd `cmd:"" help:"Comment commands"` + User UserCmd `cmd:"" help:"Workspace user commands"` Tools ToolsCmd `cmd:"" help:"List available MCP tools"` Version VersionCmd `cmd:"" help:"Show version"` } diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 0000000..d64c2c7 --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + + "github.com/lox/notion-cli/internal/cli" + "github.com/lox/notion-cli/internal/mcp" + "github.com/lox/notion-cli/internal/output" +) + +type UserCmd struct { + List UserListCmd `cmd:"" help:"List workspace users"` + Me UserMeCmd `cmd:"" help:"Show the user the current token authenticates as"` +} + +type UserListCmd struct { + Query string `help:"Filter users by name or email" short:"q"` + Limit int `help:"Maximum number of users" short:"l"` + JSON bool `help:"Output as JSON" short:"j"` +} + +func (c *UserListCmd) Run(ctx *Context) error { + ctx.JSON = c.JSON + return runUserList(ctx, c.Query, c.Limit) +} + +func runUserList(ctx *Context, query string, limit int) error { + client, err := cli.RequireClient() + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + users, err := client.ListUsers(context.Background(), &mcp.ListUsersOptions{Limit: limit, Query: query}) + if err != nil { + output.PrintError(err) + return err + } + + return output.PrintUsers(toOutputUsers(users), ctx.JSON) +} + +type UserMeCmd struct { + JSON bool `help:"Output as JSON" short:"j"` +} + +func (c *UserMeCmd) Run(ctx *Context) error { + ctx.JSON = c.JSON + return runUserMe(ctx) +} + +func runUserMe(ctx *Context) error { + client, err := cli.RequireClient() + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + user, err := client.CurrentUser(context.Background()) + if err != nil { + output.PrintError(err) + return err + } + if user == nil { + err := &output.UserError{Message: "the server returned no user for this token"} + output.PrintError(err) + return err + } + + return output.PrintUsers(toOutputUsers([]mcp.User{*user}), ctx.JSON) +} + +func toOutputUsers(users []mcp.User) []output.User { + out := make([]output.User, 0, len(users)) + for _, u := range users { + item := output.User{ID: u.ID, Name: u.Name, Type: u.Type} + if u.Person != nil { + item.Email = u.Person.Email + } + out = append(out, item) + } + return out +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go index c219911..6c6fbe7 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -311,24 +311,38 @@ func (c *Client) CreatePage(ctx context.Context, req CreatePageRequest) (*Create text := extractText(result) + return parseCreatePageResponse(text), nil +} + +// parseCreatePageResponse reads the created page out of the tool result. The +// server answers with a pages array, and falls back to prose on some page +// shapes, so the URL is recovered from the text when the array is absent. +func parseCreatePageResponse(text string) *CreatePageResponse { + var wrapper struct { + Pages []CreatePageResponse `json:"pages"` + } + if err := json.Unmarshal([]byte(text), &wrapper); err == nil && len(wrapper.Pages) > 0 { + page := wrapper.Pages[0] + if page.URL != "" || page.ID != "" { + return &page + } + } + var resp CreatePageResponse - if err := json.Unmarshal([]byte(text), &resp); err == nil && resp.URL != "" { - return &resp, nil + if err := json.Unmarshal([]byte(text), &resp); err == nil && (resp.URL != "" || resp.ID != "") { + return &resp } - url := extractURLFromText(text) - return &CreatePageResponse{URL: url}, nil + return &CreatePageResponse{URL: extractURLFromText(text)} } +// notionURLPattern matches a page URL on any Notion host. The MCP server +// answers with app.notion.com, while shared links and older responses use +// www.notion.so. +var notionURLPattern = regexp.MustCompile(`https://[a-zA-Z0-9.-]*notion\.(?:so|com)/[^\s"')>]+`) + func extractURLFromText(text string) string { - if idx := strings.Index(text, "https://www.notion.so/"); idx >= 0 { - end := idx - for end < len(text) && text[end] != ' ' && text[end] != '\n' && text[end] != '"' && text[end] != ')' && text[end] != '>' { - end++ - } - return text[idx:end] - } - return "" + return notionURLPattern.FindString(text) } // ResolveDataSourceID fetches a database by ID and extracts the data source ID diff --git a/internal/mcp/pages.go b/internal/mcp/pages.go new file mode 100644 index 0000000..b8b3ab5 --- /dev/null +++ b/internal/mcp/pages.go @@ -0,0 +1,142 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" +) + +// MovePageRequest identifies a page and the parent it is moved under. +// Exactly one of ParentPageID or ParentDatabaseID must be set. +type MovePageRequest struct { + PageID string + ParentPageID string + ParentDatabaseID string +} + +// MovePage moves a page under a new parent page or data source. +func (c *Client) MovePage(ctx context.Context, req MovePageRequest) error { + args, err := buildMovePageToolArgs(req) + if err != nil { + return err + } + + result, err := c.CallTool(ctx, "notion-move-pages", args) + if err != nil { + return err + } + return checkToolError(result) +} + +func buildMovePageToolArgs(req MovePageRequest) (map[string]any, error) { + parent := map[string]any{} + switch { + case req.ParentPageID != "" && req.ParentDatabaseID != "": + return nil, fmt.Errorf("move page: both a page and a database parent given") + case req.ParentPageID != "": + parent["page_id"] = req.ParentPageID + case req.ParentDatabaseID != "": + parent["data_source_id"] = req.ParentDatabaseID + default: + return nil, fmt.Errorf("move page: no parent given") + } + + return map[string]any{ + "page_or_database_ids": []any{req.PageID}, + "new_parent": parent, + }, nil +} + +// DuplicatePageResponse holds the identity of the page produced by a duplication. +type DuplicatePageResponse struct { + ID string `json:"page_id"` + URL string `json:"page_url"` +} + +// DuplicatePage copies a page, its content, its child pages, and its explicit +// permissions under the same parent. +func (c *Client) DuplicatePage(ctx context.Context, pageID string) (*DuplicatePageResponse, error) { + result, err := c.CallTool(ctx, "notion-duplicate-page", map[string]any{ + "page_id": pageID, + }) + if err != nil { + return nil, err + } + if err := checkToolError(result); err != nil { + return nil, err + } + + return parseDuplicatePageResponse(extractText(result)), nil +} + +func parseDuplicatePageResponse(text string) *DuplicatePageResponse { + var resp DuplicatePageResponse + if err := json.Unmarshal([]byte(text), &resp); err == nil && (resp.URL != "" || resp.ID != "") { + return &resp + } + + return &DuplicatePageResponse{URL: extractURLFromText(text)} +} + +// ListUsersOptions filters and bounds a user listing. +type ListUsersOptions struct { + Query string + Limit int +} + +// ListUsers returns the workspace users, following the server's cursor until +// the listing is exhausted or Limit is reached. +func (c *Client) ListUsers(ctx context.Context, opts *ListUsersOptions) ([]User, error) { + var ( + all []User + cursor string + ) + + for { + args := map[string]any{} + if opts != nil && opts.Query != "" { + args["query"] = opts.Query + } + if cursor != "" { + args["start_cursor"] = cursor + } + + result, err := c.CallTool(ctx, "notion-get-users", args) + if err != nil { + return nil, err + } + if err := checkToolError(result); err != nil { + return nil, err + } + + text := extractText(result) + + users, err := parseUsersResponse(text) + if err != nil { + return nil, err + } + all = append(all, users...) + + if opts != nil && opts.Limit > 0 && len(all) >= opts.Limit { + return all[:opts.Limit], nil + } + + cursor = nextUsersCursor(text) + if cursor == "" { + return all, nil + } + } +} + +func nextUsersCursor(text string) string { + var resp getUsersResponse + if err := json.Unmarshal([]byte(text), &resp); err != nil || !resp.HasMore { + return "" + } + return resp.NextCursor +} + +// CurrentUser returns the user the access token authenticates as. +func (c *Client) CurrentUser(ctx context.Context) (*User, error) { + return c.GetUser(ctx, "self") +} diff --git a/internal/mcp/pages_test.go b/internal/mcp/pages_test.go new file mode 100644 index 0000000..3942710 --- /dev/null +++ b/internal/mcp/pages_test.go @@ -0,0 +1,132 @@ +package mcp + +import "testing" + +func TestBuildMovePageToolArgsPageParent(t *testing.T) { + args, err := buildMovePageToolArgs(MovePageRequest{PageID: "page-1", ParentPageID: "parent-1"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + ids, ok := args["page_or_database_ids"].([]any) + if !ok || len(ids) != 1 || ids[0] != "page-1" { + t.Fatalf("expected page_or_database_ids [page-1], got %#v", args["page_or_database_ids"]) + } + + parent, ok := args["new_parent"].(map[string]any) + if !ok || parent["page_id"] != "parent-1" { + t.Fatalf("expected new_parent page_id parent-1, got %#v", args["new_parent"]) + } + if _, exists := parent["data_source_id"]; exists { + t.Fatalf("expected no data_source_id, got %#v", parent) + } +} + +func TestBuildMovePageToolArgsDatabaseParent(t *testing.T) { + args, err := buildMovePageToolArgs(MovePageRequest{PageID: "page-1", ParentDatabaseID: "ds-1"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + parent, ok := args["new_parent"].(map[string]any) + if !ok || parent["data_source_id"] != "ds-1" { + t.Fatalf("expected new_parent data_source_id ds-1, got %#v", args["new_parent"]) + } +} + +func TestBuildMovePageToolArgsRejectsAmbiguousParent(t *testing.T) { + if _, err := buildMovePageToolArgs(MovePageRequest{PageID: "page-1"}); err == nil { + t.Fatal("expected an error when no parent is given") + } + + req := MovePageRequest{PageID: "page-1", ParentPageID: "parent-1", ParentDatabaseID: "ds-1"} + if _, err := buildMovePageToolArgs(req); err == nil { + t.Fatal("expected an error when both parents are given") + } +} + +func TestParseDuplicatePageResponseJSON(t *testing.T) { + resp := parseDuplicatePageResponse(`{"page_id":"page-2","page_url":"https://www.notion.so/page-2"}`) + + if resp.ID != "page-2" { + t.Fatalf("expected id page-2, got %q", resp.ID) + } + if resp.URL != "https://www.notion.so/page-2" { + t.Fatalf("expected url https://www.notion.so/page-2, got %q", resp.URL) + } +} + +func TestParseDuplicatePageResponseFallsBackToURLInText(t *testing.T) { + resp := parseDuplicatePageResponse("Duplicated page https://www.notion.so/page-2 successfully") + + if resp.URL != "https://www.notion.so/page-2" { + t.Fatalf("expected url https://www.notion.so/page-2, got %q", resp.URL) + } +} + +func TestParseCreatePageResponseReadsPagesArray(t *testing.T) { + raw := `{"pages":[{"id":"page-1","url":"https://www.notion.so/page-1","properties":{"title":"T"}}]}` + + resp := parseCreatePageResponse(raw) + + if resp.ID != "page-1" { + t.Fatalf("id = %q", resp.ID) + } + if resp.URL != "https://www.notion.so/page-1" { + t.Fatalf("url = %q", resp.URL) + } +} + +func TestParseCreatePageResponseReadsFlatObject(t *testing.T) { + resp := parseCreatePageResponse(`{"id":"page-1","url":"https://www.notion.so/page-1"}`) + + if resp.ID != "page-1" || resp.URL != "https://www.notion.so/page-1" { + t.Fatalf("resp = %#v", resp) + } +} + +func TestExtractURLFromTextAcceptsBothNotionHosts(t *testing.T) { + cases := map[string]string{ + "see https://app.notion.com/p/abc?pvs=204 now": "https://app.notion.com/p/abc?pvs=204", + `{"url":"https://www.notion.so/abc"}`: "https://www.notion.so/abc", + "no link here": "", + } + + for text, want := range cases { + if got := extractURLFromText(text); got != want { + t.Fatalf("extractURLFromText(%q) = %q, want %q", text, got, want) + } + } +} + +func TestNextUsersCursor(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "has more", + text: `{"results":[],"has_more":true,"next_cursor":"cursor-2"}`, + want: "cursor-2", + }, + { + name: "last page", + text: `{"results":[],"has_more":false,"next_cursor":"cursor-2"}`, + want: "", + }, + { + name: "prose fallback", + text: "Found 3 users in the workspace", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := nextUsersCursor(tt.text); got != tt.want { + t.Errorf("nextUsersCursor() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/output/format.go b/internal/output/format.go index d7d31fb..d3a6ae5 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -279,3 +279,21 @@ func formatType(t string) string { return t } } + +func PrintUsers(users []User, asJSON bool) error { + if asJSON { + return printJSON(users) + } + + if len(users) == 0 { + fmt.Println("No users found.") + return nil + } + + table := NewTable("ID", "NAME", "TYPE", "EMAIL") + for _, u := range users { + table.AddRow(TruncateID(u.ID), Truncate(u.Name, 40), formatType(u.Type), u.Email) + } + table.Render() + return nil +} diff --git a/internal/output/types.go b/internal/output/types.go index ceaa5bc..b3eb360 100644 --- a/internal/output/types.go +++ b/internal/output/types.go @@ -45,3 +45,10 @@ type Comment struct { CreatedByName string Content string } + +type User struct { + ID string + Name string + Type string + Email string +} diff --git a/skills/notion/SKILL.md b/skills/notion/SKILL.md index 6fbb5f1..e3564d8 100644 --- a/skills/notion/SKILL.md +++ b/skills/notion/SKILL.md @@ -48,6 +48,18 @@ notion-cli auth api unset For CI/headless environments, set `NOTION_API_TOKEN`. +This is a separate credential from the OAuth one above, and one does not stand +in for the other: the OAuth token authorizes the MCP server and is rejected by +`api.notion.com` with a 401. The official API expects an internal integration +secret, in the `ntn_…` form, created at +https://www.notion.so/profile/integrations/internal. + +An internal integration only reaches the pages it is explicitly connected to, +through the page's `•••` menu under Connections, and the connection is inherited +by child pages. A valid token on an unconnected page fails with +`object_not_found`, so grant the connection at the highest parent the workflow +touches. + ### Multiple accounts Every command accepts `--profile ` (or `NOTION_CLI_PROFILE`) to target a specific Notion account. Named profiles keep credentials isolated under `~/.config/notion-cli/profiles//`; the implicit default profile uses the existing top-level paths. @@ -69,7 +81,8 @@ notion-cli page # Manage pages (list, view, create, upload, edit) notion-cli db # Manage databases (list, query, create entries) notion-cli search # Search the workspace notion-cli comment # Manage comments (list, create) -notion-cli tools # List available MCP tools +notion-cli user # List workspace users +notion-cli tools # List available MCP tools ``` ## Common Operations @@ -124,6 +137,15 @@ notion-cli page sync ./document.md --title "Custom Title" notion-cli page edit --replace "New content" notion-cli page edit --find "old text" --replace-with "new text" notion-cli page edit --find "section" --append "additional content" +notion-cli page edit -P "Status=Done" -P "Priority=1" # Update properties + +# Move a page under a new parent +notion-cli page move --parent "Engineering" # Parent page by name, URL, or ID +notion-cli page move --parent-db # Move into a database + +# Duplicate a page +notion-cli page duplicate +notion-cli page duplicate --json # Archive a page notion-cli page archive https://notion.so/... @@ -134,7 +156,7 @@ For `page upload` and `page sync`, standalone local markdown image lines like `! `page view` shows open page-level comments and inline block discussions by default. Inline discussions are rendered beside their anchor text, with the anchor wrapped in `[[...]]` and the discussion shown immediately below it. Use `--no-comments` when you only want the page body, `--raw` to inspect the original Notion markup, and `--json` when an agent needs the page plus the `Comments` array. -`page archive` uses the official API fallback path and requires `notion-cli auth api setup` or `NOTION_API_TOKEN`. +`page archive` uses the official API fallback path and requires `notion-cli auth api setup` or `NOTION_API_TOKEN`. The MCP server exposes no tool that trashes a page, so this is the only way to remove one; without the integration secret, the closest an MCP-only session gets is `page move`, gathering throwaway pages under a single parent for a human to delete. ### Edit mode guardrails @@ -143,9 +165,26 @@ For `page upload` and `page sync`, standalone local markdown image lines like `! 1. `--replace "..."` for full-page replacement. 2. `--find "..." --replace-with "..."` for targeted replacement. 3. `--find "..." --append "..."` for append-after-match. +4. `-P key=value` (repeatable) for property updates, including the title. When a targeted edit fails (for example MCP validation errors), fall back to full replacement by fetching content, editing locally, then applying `--replace`. +`page move` takes exactly one of `--parent` or `--parent-db`. `page duplicate` copies the page, its child pages, and its explicit permissions under the same parent, so it is the way to obtain a page that keeps a permission grant the parent does not hand down. + +### Users + +```bash +notion-cli user list # List workspace users (paginated) +notion-cli user list --query alice # Filter by name or email +notion-cli user list --limit 20 # Cap the number of users returned +notion-cli user list --json # Output as JSON +notion-cli user me # Show the user the token authenticates as +``` + +`user list` follows the server cursor until the workspace is exhausted, so large +workspaces return every member rather than only the first page. Use `--query` to +let the server filter instead of paging through everything. + ### Databases All database commands accept a **URL**, **name**, or **ID** to identify databases.