From 96445b3c938caa4764e1bbc0e2ac474ca5cd2320 Mon Sep 17 00:00:00 2001 From: Simone Mariotti Date: Mon, 31 Aug 2026 16:20:26 +0200 Subject: [PATCH] Add page move/duplicate and user commands, fix page JSON output New CLI surfaces for MCP tools that had none: page move --parent | --parent-db notion-move-pages page duplicate [--json] notion-duplicate-page user list [--query] [--limit] [--json] notion-get-users user me [--json] notion-get-users user_id=self Client.MovePage, Client.DuplicatePage and Client.ListUsers live in internal/mcp/pages.go, with the tool-argument building and response parsing split into pure functions covered by unit tests. Commands resolve pages and databases by URL, name, or ID like the rest of the CLI. user list follows the server cursor to the end so large workspaces return every member rather than only the first page; --query pushes the filter to the server and --limit bounds the result. Two response shapes differ from what the client assumed, so --json returned an empty id and url where a script needs them to chain calls: * notion-create-pages answers with a {"pages":[...]} wrapper, not a flat object * notion-duplicate-page answers with page_id and page_url, not id and url * the server returns app.notion.com URLs, while URL extraction matched only www.notion.so; it now matches either host * when the server answers in prose, the duplicated page id is recovered from the URL README.md and skills/notion/SKILL.md document the new commands. SKILL.md also records that page edit -P key=value already handles property edits, and that the official API fallback needs an internal integration secret from notion.so/profile/integrations/internal (the MCP OAuth token is rejected by api.notion.com with 401), which only reaches pages explicitly connected to it. --- README.md | 10 +++ cmd/page.go | 134 ++++++++++++++++++++++++++++++++-- cmd/page_move_test.go | 44 ++++++++++++ cmd/root.go | 1 + cmd/user.go | 83 ++++++++++++++++++++++ internal/mcp/client.go | 38 ++++++---- internal/mcp/pages.go | 142 +++++++++++++++++++++++++++++++++++++ internal/mcp/pages_test.go | 132 ++++++++++++++++++++++++++++++++++ internal/output/format.go | 18 +++++ internal/output/types.go | 7 ++ skills/notion/SKILL.md | 43 ++++++++++- 11 files changed, 631 insertions(+), 21 deletions(-) create mode 100644 cmd/page_move_test.go create mode 100644 cmd/user.go create mode 100644 internal/mcp/pages.go create mode 100644 internal/mcp/pages_test.go 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.