Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ notion-cli page create --title "T" --content "Body text"
notion-cli page create --title "T" --parent <page-id>
notion-cli page archive <page-url-or-id> # Archive a page via the official API

notion-cli page move <page> --parent "Engineering" # Move under another page
notion-cli page move <page> --parent-db <db-id> # Move into a database
notion-cli page duplicate <page> # Copy a page with children and permissions
notion-cli page duplicate <page> --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
Expand Down Expand Up @@ -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
Expand Down
134 changes: 127 additions & 7 deletions cmd/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions cmd/page_move_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
83 changes: 83 additions & 0 deletions cmd/user.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 26 additions & 12 deletions internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading