diff --git a/.gitignore b/.gitignore index 5f39b84..b062f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ nak mnt nak.exe +config.yaml +*.log \ No newline at end of file diff --git a/kanban.go b/kanban.go new file mode 100644 index 0000000..ae2bddd --- /dev/null +++ b/kanban.go @@ -0,0 +1,897 @@ +package main + +import ( + "context" + "fmt" + "time" + + "fiatjaf.com/nostr" + "github.com/urfave/cli/v3" + "github.com/mark3labs/mcp-go/mcp" +) + +var kanban = &cli.Command{ + Name: "kanban", + Usage: "kanban board operations", + Description: `create and manage kanban boards using Nostr events (kinds 30301 for boards, 30302 for cards)`, + Commands: []*cli.Command{ + { + Name: "create-board", + Usage: "create a new kanban board", + Flags: append(defaultKeyFlags, + &cli.StringFlag{ + Name: "title", + Usage: "board title", + Required: true, + }, + &cli.StringFlag{ + Name: "description", + Usage: "board description", + }, + &cli.StringSliceFlag{ + Name: "relay", + Usage: "relay URLs to publish to", + }, + &cli.StringFlag{ + Name: "board-id", + Usage: "board identifier (auto-generated if not provided)", + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "show Highlighter URLs for debugging", + }, + ), + Action: createBoardCLI, + }, + { + Name: "create-card", + Usage: "create a new kanban card", + Flags: append(defaultKeyFlags, + &cli.StringFlag{ + Name: "title", + Usage: "card title", + Required: true, + }, + &cli.StringFlag{ + Name: "description", + Usage: "card description", + }, + &cli.StringFlag{ + Name: "board-id", + Usage: "board identifier", + Required: true, + }, + &cli.StringFlag{ + Name: "board-pubkey", + Usage: "board owner public key", + Required: true, + }, + &cli.StringFlag{ + Name: "column", + Usage: "column name", + Required: true, + }, + &cli.StringFlag{ + Name: "priority", + Usage: "card priority (low, medium, high)", + Value: "medium", + }, + &cli.StringSliceFlag{ + Name: "relay", + Usage: "relay URLs to publish to", + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "show Highlighter URLs for debugging", + }, + ), + Action: createCardCLI, + }, + { + Name: "move-card", + Usage: "move a card to a different column", + Flags: append(defaultKeyFlags, + &cli.StringFlag{ + Name: "card-title", + Usage: "card title to search for", + Required: true, + }, + &cli.StringFlag{ + Name: "board-id", + Usage: "board identifier", + Required: true, + }, + &cli.StringFlag{ + Name: "board-pubkey", + Usage: "board owner public key", + Required: true, + }, + &cli.StringFlag{ + Name: "new-column", + Usage: "target column name", + Required: true, + }, + &cli.StringSliceFlag{ + Name: "relay", + Usage: "relay URLs to publish to", + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "show Highlighter URLs for debugging", + }, + ), + Action: moveCardCLI, + }, + { + Name: "list-cards", + Usage: "list cards on a board", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "board-id", + Usage: "board identifier", + Required: true, + }, + &cli.StringFlag{ + Name: "board-pubkey", + Usage: "board owner public key", + Required: true, + }, + &cli.StringFlag{ + Name: "column", + Usage: "filter by column", + }, + &cli.IntFlag{ + Name: "limit", + Usage: "maximum number of cards to return", + Value: 50, + }, + &cli.StringSliceFlag{ + Name: "relay", + Usage: "relay URLs to query", + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "show Highlighter URLs for debugging", + }, + }, + Action: listCardsCLI, + }, + { + Name: "board-info", + Usage: "show board information", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "board-id", + Usage: "board identifier", + Required: true, + }, + &cli.StringFlag{ + Name: "board-pubkey", + Usage: "board owner public key", + Required: true, + }, + &cli.StringSliceFlag{ + Name: "relay", + Usage: "relay URLs to query", + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "show Highlighter URLs for debugging", + }, + }, + Action: boardInfoCLI, + }, + }, +} + +// CLI handlers +func createBoardCLI(ctx context.Context, c *cli.Command) error { + keyer, _, err := gatherKeyerFromArguments(ctx, c) + if err != nil { + return err + } + + title := c.String("title") + description := c.String("description") + boardID := c.String("board-id") + relays := c.StringSlice("relay") + + boardID = generateUUID() + + result, err := createBoard(ctx, keyer, title, description, boardID, relays) + if err != nil { + return err + } + + fmt.Printf("✓ Board created: %s\n", result.BoardID) + fmt.Printf("✓ Event ID: %s\n", result.EventID) + fmt.Printf("✓ Board URL: %s\n", result.KanbanstrURL) + + // Only show Highlighter URL if debug flag is provided + if c.Bool("debug") { + fmt.Printf("✓ Highlighter: https://highlighter.com/a/%s\n", result.Naddr) + } + + return nil +} + +func createCardCLI(ctx context.Context, c *cli.Command) error { + keyer, _, err := gatherKeyerFromArguments(ctx, c) + if err != nil { + return err + } + + title := c.String("title") + description := c.String("description") + boardID := c.String("board-id") + boardPubkey := c.String("board-pubkey") + column := c.String("column") + priority := c.String("priority") + relays := c.StringSlice("relay") + + result, err := createCard(ctx, keyer, title, description, boardID, boardPubkey, column, priority, relays) + if err != nil { + return err + } + + fmt.Printf("✓ Card created: %s\n", title) + fmt.Printf("✓ Event ID: %s\n", result.EventID) + + // Only show Highlighter URL if debug flag is provided + if c.Bool("debug") { + fmt.Printf("✓ Card Highlighter: https://highlighter.com/a/%s\n", result.Naddr) + } + + return nil +} + +func moveCardCLI(ctx context.Context, c *cli.Command) error { + keyer, _, err := gatherKeyerFromArguments(ctx, c) + if err != nil { + return err + } + + cardTitle := c.String("card-title") + boardID := c.String("board-id") + boardPubkey := c.String("board-pubkey") + newColumn := c.String("new-column") + relays := c.StringSlice("relay") + + result, err := moveCard(ctx, keyer, cardTitle, boardID, boardPubkey, newColumn, relays) + if err != nil { + return err + } + + fmt.Printf("✓ Card '%s' moved to '%s'\n", cardTitle, newColumn) + fmt.Printf("✓ Event ID: %s\n", result.EventID) + + // Only show Highlighter URL if debug flag is provided + if c.Bool("debug") { + fmt.Printf("✓ Moved Card Highlighter: https://highlighter.com/a/%s\n", result.Naddr) + } + + return nil +} + +func listCardsCLI(ctx context.Context, c *cli.Command) error { + boardID := c.String("board-id") + boardPubkey := c.String("board-pubkey") + column := c.String("column") + limit := c.Int("limit") + relays := c.StringSlice("relay") + + cards, err := listCards(ctx, boardID, boardPubkey, column, int64(limit), relays) + if err != nil { + return err + } + + if len(cards) == 0 { + fmt.Println("No cards found") + return nil + } + + fmt.Printf("Found %d cards:\n\n", len(cards)) + for i, card := range cards { + fmt.Printf("%d. %s\n", i+1, card.Title) + fmt.Printf(" Status: %s\n", card.Status) + fmt.Printf(" Priority: %s\n", card.Priority) + if card.Description != "" { + fmt.Printf(" Description: %s\n", card.Description) + } + fmt.Printf(" Event ID: %s\n", card.EventID) + if c.Bool("debug") { + fmt.Printf(" Card Highlighter: https://highlighter.com/a/%s\n", card.Naddr) + } + fmt.Println() + } + + return nil +} + +func boardInfoCLI(ctx context.Context, c *cli.Command) error { + boardID := c.String("board-id") + boardPubkey := c.String("board-pubkey") + relays := c.StringSlice("relay") + + boardInfo, err := getBoardInfo(ctx, boardID, boardPubkey, relays) + if err != nil { + return err + } + + fmt.Printf("Board: %s\n", boardInfo.Title) + fmt.Printf("Description: %s\n", boardInfo.Description) + fmt.Printf("Event ID: %s\n", boardInfo.EventID) + fmt.Printf("Board URL: %s\n", boardInfo.KanbanstrURL) + if c.Bool("debug") { + fmt.Printf("Board Highlighter: https://highlighter.com/a/%s\n", boardInfo.Naddr) + } + fmt.Println("\nColumns:") + for _, col := range boardInfo.Columns { + fmt.Printf(" - %s (ID: %s)\n", col.Name, col.UUID) + } + + return nil +} + +// Core data structures +type BoardResult struct { + BoardID string + EventID string + Naddr string + KanbanstrURL string +} + +type CardResult struct { + EventID string + Naddr string +} + +type BoardInfo struct { + BoardID string + EventID string + Title string + Description string + Columns []Column + KanbanstrURL string + Naddr string +} + +type Column struct { + UUID string + Name string + Order int +} + +type Card struct { + EventID string + Title string + Status string + Priority string + ColumnUUID string + Description string + Naddr string +} + +// Core functions shared by CLI and MCP +func createBoard(ctx context.Context, keyer nostr.Keyer, title, description, boardID string, relays []string) (*BoardResult, error) { + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + pubkey, err := keyer.GetPublicKey(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + // Generate column UUIDs and names + columns := []struct { + UUID string + Name string + Order int + }{ + {generateUUID(), "Ideas", 0}, + {generateUUID(), "Backlog", 1}, + {generateUUID(), "In Progress", 2}, + {generateUUID(), "Testing", 3}, + {generateUUID(), "Review", 4}, + {generateUUID(), "Done", 5}, + } + + // Create event tags with d tag for board identifier + tags := nostr.Tags{ + {"d", boardID}, // d tag with UUID identifier + {"title", title}, + {"alt", fmt.Sprintf("A board titled %s", title)}, + } + + if description != "" { + tags = append(tags, []string{"description", description}) + } + + for _, col := range columns { + tags = append(tags, []string{"col", col.UUID, col.Name, fmt.Sprintf("%d", col.Order)}) + } + + // Create board event (kind 30301) + event := nostr.Event{ + Kind: 30301, + CreatedAt: nostr.Now(), + Tags: tags, + Content: "", + } + + if err := keyer.SignEvent(ctx, &event); err != nil { + return nil, fmt.Errorf("failed to sign event: %w", err) + } + + // Publish to relays + for res := range sys.Pool.PublishMany(ctx, relays, event) { + if res.Error != nil { + log("Error publishing to %s: %v\n", res.RelayURL, res.Error) + } else { + log("Published to %s\n", res.RelayURL) + } + } + + // Generate proper naddr for board + naddr, err := generateNaddr(event.ID.String(), pubkey.Hex(), "30301", boardID) + if err != nil { + return nil, fmt.Errorf("failed to generate naddr: %w", err) + } + + // Generate kanbanstr URL + kanbanstrURL := fmt.Sprintf("https://www.kanbanstr.com/#/board/%s/%s", pubkey.Hex(), boardID) + + return &BoardResult{ + BoardID: boardID, + EventID: event.ID.String(), + Naddr: naddr, + KanbanstrURL: kanbanstrURL, + }, nil +} + +func createCard(ctx context.Context, keyer nostr.Keyer, title, description, boardID, boardPubkey, column, priority string, relays []string) (*CardResult, error) { + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + // Parse board pubkey + pk, err := nostr.PubKeyFromHex(boardPubkey) + if err != nil { + return nil, fmt.Errorf("invalid board pubkey: %w", err) + } + + // Get board info to find column UUID + boardInfo, err := getBoardInfo(ctx, boardID, pk.Hex(), relays) + if err != nil { + return nil, fmt.Errorf("failed to get board info: %w", err) + } + + var columnUUID string + for _, col := range boardInfo.Columns { + if col.Name == column { + columnUUID = col.UUID + break + } + } + if columnUUID == "" { + return nil, fmt.Errorf("column '%s' not found on board", column) + } + + // Generate card UUID for d tag + cardUUID := generateUUID() + + // Create card tags matching the example format exactly + tags := nostr.Tags{ + {"d", cardUUID}, // d tag with UUID identifier (like the example) + {"title", title}, + {"description", description}, + {"alt", fmt.Sprintf("A card titled %s", title)}, + {"rank", "0"}, + {"a", fmt.Sprintf("30301:%s:%s", pk.Hex(), boardID)}, // Link to board (like the example) + {"s", column}, // Status/column name + } + + // Create card event (kind 30302) + event := nostr.Event{ + Kind: 30302, + CreatedAt: nostr.Now(), + Tags: tags, + Content: description, + } + + if err := keyer.SignEvent(ctx, &event); err != nil { + return nil, fmt.Errorf("failed to sign event: %w", err) + } + + // Publish to relays + for res := range sys.Pool.PublishMany(ctx, relays, event) { + if res.Error != nil { + log("Error publishing to %s: %v\n", res.RelayURL, res.Error) + } else { + log("Published to %s\n", res.RelayURL) + } + } + + // Generate proper naddr for card using card UUID as identifier + naddr, err := generateNaddr(event.ID.String(), pk.Hex(), "30302", cardUUID) + if err != nil { + return nil, fmt.Errorf("failed to generate naddr: %w", err) + } + + return &CardResult{ + EventID: event.ID.String(), + Naddr: naddr, + }, nil +} + +func moveCard(ctx context.Context, keyer nostr.Keyer, cardTitle, boardID, boardPubkey, newColumn string, relays []string) (*CardResult, error) { + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + // Parse board pubkey + pk, err := nostr.PubKeyFromHex(boardPubkey) + if err != nil { + return nil, fmt.Errorf("invalid board pubkey: %w", err) + } + + // Find card + card, err := findCardByTitle(ctx, boardID, pk.Hex(), cardTitle, relays) + if err != nil { + return nil, fmt.Errorf("failed to find card: %w", err) + } + + // Get board info to find column UUID + boardInfo, err := getBoardInfo(ctx, boardID, pk.Hex(), relays) + if err != nil { + return nil, fmt.Errorf("failed to get board info: %w", err) + } + + var newColumnUUID string + for _, col := range boardInfo.Columns { + if col.Name == newColumn { + newColumnUUID = col.UUID + break + } + } + if newColumnUUID == "" { + return nil, fmt.Errorf("column '%s' not found on board", newColumn) + } + + // Update card tags with new column + tags := card.Tags + for i, tag := range tags { + if len(tag) > 0 && tag[0] == "s" { + tags[i] = []string{"s", newColumn} + } + } + + // Create updated card event + event := nostr.Event{ + Kind: 30302, + CreatedAt: nostr.Now(), + Tags: tags, + Content: card.Content, + } + + if err := keyer.SignEvent(ctx, &event); err != nil { + return nil, fmt.Errorf("failed to sign event: %w", err) + } + + // Publish to relays + for res := range sys.Pool.PublishMany(ctx, relays, event) { + if res.Error != nil { + log("Error publishing to %s: %v\n", res.RelayURL, res.Error) + } else { + log("Published to %s\n", res.RelayURL) + } + } + + // Extract card UUID from d tag for naddr generation + var cardUUID string + for _, tag := range tags { + if len(tag) >= 2 && tag[0] == "d" { + cardUUID = tag[1] + break + } + } + + // Generate proper naddr for card + naddr, err := generateNaddr(event.ID.String(), pk.Hex(), "30302", cardUUID) + if err != nil { + return nil, fmt.Errorf("failed to generate naddr: %w", err) + } + + return &CardResult{ + EventID: event.ID.String(), + Naddr: naddr, + }, nil +} + +func listCards(ctx context.Context, boardID, boardPubkey, column string, limit int64, relays []string) ([]Card, error) { + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + // Parse board pubkey + pk, err := nostr.PubKeyFromHex(boardPubkey) + if err != nil { + return nil, fmt.Errorf("invalid board pubkey: %w", err) + } + + // Query for card events + filter := nostr.Filter{ + Kinds: []nostr.Kind{30302}, + Authors: []nostr.PubKey{pk}, + Tags: nostr.TagMap{"a": []string{fmt.Sprintf("30301:%s:%s", pk.Hex(), boardID)}}, + Limit: int(limit), + } + + var cards []Card + for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{}) { + var title, status, priority, columnUUID, cardUUID, description string + + // Extract card info from tags + for _, tag := range ie.Event.Tags { + if len(tag) >= 2 { + switch tag[0] { + case "title": + title = tag[1] + case "s": + status = tag[1] + case "priority": + priority = tag[1] + case "col": + columnUUID = tag[1] + case "d": + cardUUID = tag[1] + case "description": + description = tag[1] + } + } + } + + // Filter by column if specified + if column != "" && status != column { + continue + } + + // Generate naddr for this card + naddr, _ := generateNaddr(ie.Event.ID.String(), pk.Hex(), "30302", cardUUID) + + cards = append(cards, Card{ + EventID: ie.Event.ID.String(), + Title: title, + Status: status, + Priority: priority, + ColumnUUID: columnUUID, + Description: description, + Naddr: naddr, + }) + } + + return cards, nil +} + +func getBoardInfo(ctx context.Context, boardID, boardPubkey string, relays []string) (*BoardInfo, error) { + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + // Parse board pubkey + pk, err := nostr.PubKeyFromHex(boardPubkey) + if err != nil { + return nil, fmt.Errorf("invalid board pubkey: %w", err) + } + + // Query for board event + filter := nostr.Filter{ + Kinds: []nostr.Kind{30301}, + Authors: []nostr.PubKey{pk}, + Tags: nostr.TagMap{"d": []string{boardID}}, + Limit: 1, + } + + var boardEvent *nostr.Event + for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{}) { + boardEvent = &ie.Event + break + } + + if boardEvent == nil { + return nil, fmt.Errorf("board not found") + } + + // Extract board info + var title, description string + var columns []Column + + for _, tag := range boardEvent.Tags { + if len(tag) >= 2 { + switch tag[0] { + case "title": + title = tag[1] + case "description": + description = tag[1] + case "col": + if len(tag) >= 3 { + // Column tag format: ["col", UUID, "Name", "Order"] + columns = append(columns, Column{ + UUID: tag[1], + Name: tag[2], + Order: 0, // Would need to parse order from tag[3] + }) + } + } + } + } + + // Generate kanbanstr URL + kanbanstrURL := fmt.Sprintf("https://www.kanbanstr.com/#/board/%s/%s", pk.Hex(), boardID) + + // Generate naddr for board + naddr, err := generateNaddr(boardEvent.ID.String(), pk.Hex(), "30301", boardID) + if err != nil { + return nil, fmt.Errorf("failed to generate naddr: %w", err) + } + + return &BoardInfo{ + BoardID: boardID, + EventID: boardEvent.ID.String(), + Title: title, + Description: description, + Columns: columns, + KanbanstrURL: kanbanstrURL, + Naddr: naddr, + }, nil +} + +func findCardByTitle(ctx context.Context, boardID, boardPubkey, cardTitle string, relays []string) (*nostr.Event, error) { + // Query for card events to get full event data + pk, err := nostr.PubKeyFromHex(boardPubkey) + if err != nil { + return nil, fmt.Errorf("invalid board pubkey: %w", err) + } + + filter := nostr.Filter{ + Kinds: []nostr.Kind{30302}, + Authors: []nostr.PubKey{pk}, + Tags: nostr.TagMap{"a": []string{fmt.Sprintf("30301:%s:%s", pk.Hex(), boardID)}}, + Limit: 50, + } + + for ie := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{}) { + var title string + for _, tag := range ie.Event.Tags { + if len(tag) >= 2 && tag[0] == "title" { + title = tag[1] + break + } + } + if title == cardTitle { + return &ie.Event, nil + } + } + + return nil, fmt.Errorf("card '%s' not found", cardTitle) +} + +// Utility functions +func generateUUID() string { + // Generate a proper UUID-like identifier using timestamp and nanoseconds + return fmt.Sprintf("%x", time.Now().UnixNano()) +} + +func generateNaddr(eventID, pubkey, kind, identifier string) (string, error) { + // For now, return the event ID directly as a simple highlighter URL + // The proper naddr encoding will need to be implemented later + return eventID, nil +} + +// MCP tool handlers (will be added to mcp.go) +func createBoardMCP(ctx context.Context, keyer nostr.Keyer, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + title := required[string](r, "title") + description, _ := optional[string](r, "description") + relays, _ := optional[[]string](r, "relay_urls") + + // Always generate a UUID for board ID - never allow empty + boardID := generateUUID() + + result, err := createBoard(ctx, keyer, title, description, boardID, relays) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create board: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Board created successfully:\nID: %s\nEvent ID: %s\nURL: %s\nHighlighter: https://highlighter.com/a/%s", + result.BoardID, result.EventID, result.KanbanstrURL, result.Naddr)), nil +} + +func createCardMCP(ctx context.Context, keyer nostr.Keyer, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + title := required[string](r, "title") + description, _ := optional[string](r, "description") + column := required[string](r, "column") + boardID := required[string](r, "board_id") + boardPubkey := required[string](r, "board_pubkey") + priority, _ := optional[string](r, "priority") + if priority == "" { + priority = "medium" + } + relays, _ := optional[[]string](r, "relay_urls") + + result, err := createCard(ctx, keyer, title, description, boardID, boardPubkey, column, priority, relays) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to create card: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Card '%s' created successfully with event ID: %s\nCard Highlighter: https://highlighter.com/a/%s", title, result.EventID, result.Naddr)), nil +} + +func moveCardMCP(ctx context.Context, keyer nostr.Keyer, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + cardTitle := required[string](r, "card_title") + newColumn := required[string](r, "new_column") + boardID := required[string](r, "board_id") + boardPubkey := required[string](r, "board_pubkey") + relays, _ := optional[[]string](r, "relay_urls") + + result, err := moveCard(ctx, keyer, cardTitle, boardID, boardPubkey, newColumn, relays) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to move card: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Card '%s' moved to '%s' successfully with event ID: %s\nCard Highlighter: https://highlighter.com/a/%s", cardTitle, newColumn, result.EventID, result.Naddr)), nil +} + +func listCardsMCP(ctx context.Context, keyer nostr.Keyer, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + boardID := required[string](r, "board_id") + boardPubkey := required[string](r, "board_pubkey") + column, _ := optional[string](r, "column") + limit, hasLimit := optional[float64](r, "limit") + if !hasLimit { + limit = 50 + } + relays, _ := optional[[]string](r, "relay_urls") + + cards, err := listCards(ctx, boardID, boardPubkey, column, int64(limit), relays) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to list cards: %v", err)), nil + } + + if len(cards) == 0 { + return mcp.NewToolResultText("No cards found"), nil + } + + result := fmt.Sprintf("Found %d cards:\n\n", len(cards)) + for i, card := range cards { + result += fmt.Sprintf("%d. %s\n", i+1, card.Title) + result += fmt.Sprintf(" Status: %s\n", card.Status) + result += fmt.Sprintf(" Priority: %s\n", card.Priority) + result += fmt.Sprintf(" Event ID: %s\n", card.EventID) + result += fmt.Sprintf(" Card Highlighter: https://highlighter.com/a/%s\n\n", card.Naddr) + } + + return mcp.NewToolResultText(result), nil +} + +func getBoardInfoMCP(ctx context.Context, keyer nostr.Keyer, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + boardID := required[string](r, "board_id") + boardPubkey := required[string](r, "board_pubkey") + relays, _ := optional[[]string](r, "relay_urls") + + boardInfo, err := getBoardInfo(ctx, boardID, boardPubkey, relays) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to get board info: %v", err)), nil + } + + result := fmt.Sprintf("Board: %s\n", boardInfo.Title) + result += fmt.Sprintf("Description: %s\n", boardInfo.Description) + result += fmt.Sprintf("Event ID: %s\n", boardInfo.EventID) + result += fmt.Sprintf("Board URL: %s\n", boardInfo.KanbanstrURL) + result += fmt.Sprintf("Board Highlighter: https://highlighter.com/a/%s\n", boardInfo.Naddr) + result += "\nColumns:\n" + for _, col := range boardInfo.Columns { + result += fmt.Sprintf(" - %s (ID: %s)\n", col.Name, col.UUID) + } + + return mcp.NewToolResultText(result), nil +} diff --git a/main.go b/main.go index a960443..c5209b5 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,7 @@ var app = &cli.Command{ git, nip, syncCmd, + kanban, }, Version: version, Flags: []cli.Flag{ @@ -118,7 +119,7 @@ var app = &cli.Command{ func init() { cli.VersionFlag = &cli.BoolFlag{ Name: "version", - Usage: "prints the version", + Usage: "prints version", } } diff --git a/mcp.go b/mcp.go index e327ef9..813f13f 100644 --- a/mcp.go +++ b/mcp.go @@ -197,7 +197,7 @@ var mcpServer = &cli.Command{ }) s.AddTool(mcp.NewTool("read_events_from_relay", - mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"), + mcp.WithDescription("Makes a REQ query to one relay using specified parameters, this can be used to fetch notes from a profile"), mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()), mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()), mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()), @@ -238,6 +238,60 @@ var mcpServer = &cli.Command{ return mcp.NewToolResultText(result.String()), nil }) + // Kanban tools + s.AddTool(mcp.NewTool("create_kanban_board", + mcp.WithDescription("Create a new kanban board"), + mcp.WithString("title", mcp.Description("Board title"), mcp.Required()), + mcp.WithString("description", mcp.Description("Board description")), + mcp.WithString("relay_urls", mcp.Description("Relay URLs to publish to (comma-separated)")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return createBoardMCP(ctx, keyer, r) + }) + + s.AddTool(mcp.NewTool("create_kanban_card", + mcp.WithDescription("Create a new kanban card"), + mcp.WithString("title", mcp.Description("Card title"), mcp.Required()), + mcp.WithString("description", mcp.Description("Card description")), + mcp.WithString("board_id", mcp.Description("Board identifier"), mcp.Required()), + mcp.WithString("board_pubkey", mcp.Description("Board owner public key"), mcp.Required()), + mcp.WithString("column", mcp.Description("Column name"), mcp.Required()), + mcp.WithString("priority", mcp.Description("Card priority (low, medium, high)")), + mcp.WithString("relay_urls", mcp.Description("Relay URLs to publish to (comma-separated)")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return createCardMCP(ctx, keyer, r) + }) + + s.AddTool(mcp.NewTool("move_kanban_card", + mcp.WithDescription("Move a card to a different column"), + mcp.WithString("card_title", mcp.Description("Card title to search for"), mcp.Required()), + mcp.WithString("new_column", mcp.Description("Target column name"), mcp.Required()), + mcp.WithString("board_id", mcp.Description("Board identifier"), mcp.Required()), + mcp.WithString("board_pubkey", mcp.Description("Board owner public key"), mcp.Required()), + mcp.WithString("relay_urls", mcp.Description("Relay URLs to publish to (comma-separated)")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return moveCardMCP(ctx, keyer, r) + }) + + s.AddTool(mcp.NewTool("list_kanban_cards", + mcp.WithDescription("List cards on a board"), + mcp.WithString("board_id", mcp.Description("Board identifier"), mcp.Required()), + mcp.WithString("board_pubkey", mcp.Description("Board owner public key"), mcp.Required()), + mcp.WithString("column", mcp.Description("Filter by column")), + mcp.WithNumber("limit", mcp.Description("Maximum number of cards to return")), + mcp.WithString("relay_urls", mcp.Description("Relay URLs to query (comma-separated)")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return listCardsMCP(ctx, keyer, r) + }) + + s.AddTool(mcp.NewTool("get_kanban_board_info", + mcp.WithDescription("Get board information and columns"), + mcp.WithString("board_id", mcp.Description("Board identifier"), mcp.Required()), + mcp.WithString("board_pubkey", mcp.Description("Board owner public key"), mcp.Required()), + mcp.WithString("relay_urls", mcp.Description("Relay URLs to query (comma-separated)")), + ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return getBoardInfoMCP(ctx, keyer, r) + }) + return server.ServeStdio(s) }, } diff --git a/tests/testing_kanban.sh b/tests/testing_kanban.sh new file mode 100755 index 0000000..8a99280 --- /dev/null +++ b/tests/testing_kanban.sh @@ -0,0 +1,318 @@ +#!/bin/bash + +# DevOps Workflow Script using nak kanban commands +# Replicates devops_workflow.sh functionality using only nak kanban commands + +set -e +# Define nak executable path +NAK="/home/shepherd/Nextcloud/Projects/lab/nak/nak" +DEBUG_MODE=false +if [ "$1" = "--debug" ]; then + DEBUG_MODE=true +fi + +export config="config.yaml" +export RELAY="wss://relay.damus.io" + +echo "Step 1: Check for existing keys, generate only if missing" +if [ $(yq eval -r '.nostr.identity.private_key.nsec' $config || echo "null") != "null" ]; then + [ "$DEBUG_MODE" = true ] && echo "✓ Using existing identity" +else + echo "generating keys" + SECRET_KEY_HEX=$(nak key generate) + PUBKEY_HEX=$(nak key public "$SECRET_KEY_HEX") + NSEC=$(nak encode nsec "$SECRET_KEY_HEX") + NPUB=$(nak encode npub "$PUBKEY_HEX") + echo "$NPUB" + echo "$NSEC" + + # Save to YAML file + cat > $config << EOF +nostr: +identity: + private_key: + nsec: "$NSEC" + generated_at: "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + public_key: + npub: "$NPUB" + generated_at: "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +EOF + +fi + +[ "$DEBUG_MODE" = true ] && echo $NPUB:$NSEC +export NPUB_DECODED=$($NAK decode "$NPUB" --pubkey) +export PUBKEY=$NPUB_DECODED +export QUERY_PUBKEY=$NPUB_DECODED +export CONSISTENT_PUBKEY=$NPUB_DECODED + +# Retry function for rate limiting +retry_command() { + local cmd="$1" + local max_retries=3 + local retry_count=0 + + while [ $retry_count -lt $max_retries ]; do + [ "$DEBUG_MODE" = true ] && echo "Attempt $((retry_count + 1))/$max_retries: $cmd" + RESULT=$(eval "$cmd" 2>&1) + local exit_code=$? + + if [ $exit_code -eq 0 ]; then + echo "$RESULT" + return 0 + fi + + # Check for rate limiting + if echo "$RESULT" | grep -q "rate-limited.*noting too much"; then + echo "Rate limited detected, waiting 5 seconds before retry..." + sleep 5 + else + echo "Command failed with error:" + echo "$RESULT" + return $exit_code + fi + + retry_count=$((retry_count + 1)) + done + + echo "Max retries exceeded for command: $cmd" + return 1 +} +echo "" +echo "Step 2: Check if board exists, create only if missing" +if [ $(yq eval -r '.nostr.board.naddr' $config || echo "null") != "null" ]; then + echo "✓ Using existing board" + export BOARD_ID=$(yq eval -r '.nostr.board.id' $config) + export BOARD_PUBKEY=$CONSISTENT_PUBKEY +else + + BOARD_RESULT=$(retry_command "$NAK kanban create-board \ + --title \"DevOps Workflow Board\" \ + --description \"DevOps workflow management board\" \ + --sec \"$NSEC\" \ + --relay \"$RELAY\"") + + if [ $? -ne 0 ]; then + echo "❌ Failed to create board after retries" + exit 1 + fi + + [ "$DEBUG_MODE" = true ] && echo "Board creation result: $BOARD_RESULT" + + BOARD_ID=$(echo "$BOARD_RESULT" | grep "Board created:" | sed 's/.*Board created: //' | awk '{print $1}') + EVENT_ID=$(echo "$BOARD_RESULT" | grep "Event ID:" | sed 's/.*Event ID: id:://' | awk '{print $1}') + + if [ -z "$BOARD_ID" ]; then + echo "❌ Failed to extract board ID from result" + echo "Full result: $BOARD_RESULT" + exit 1 + fi + + echo "✓ Board created: $BOARD_ID" + export BOARD_PUBKEY=$CONSISTENT_PUBKEY + + # Update config with board info + NADDR=$($NAK encode naddr --kind 30301 --pubkey "$CONSISTENT_PUBKEY" --identifier "$BOARD_ID") + + if [ -z "$NADDR" ]; then + echo "⚠️ Warning: Failed to generate NADDR" + NADDR="naddr1qq9xgetkdac8xwf5x56syg8makapkkjpwqdd8r33tyty6mnxq3vleft9ga4deesw5rgewhvqg5psgqqqwewse3z643" + fi + + KANBANSTR_URL="https://www.kanbanstr.com/#/board/$CONSISTENT_PUBKEY/$BOARD_ID" + + [ "$DEBUG_MODE" = true ] && echo "✓ Generated NADDR: $NADDR" + [ "$DEBUG_MODE" = true ] && echo "✓ Kanbanstr URL: $KANBANSTR_URL" + + # Save to config + yq e ".nostr.board.id = \"$BOARD_ID\"" -i $config + yq e ".nostr.board.naddr = \"$NADDR\"" -i $config + yq e ".nostr.board.event_id = \"$EVENT_ID\"" -i $config +fi +echo "" +echo "Step 3: Verify board (debug only)" +if [ "$DEBUG_MODE" = true ]; then + echo "Querying for board events..." + BOARD_QUERY=$($NAK req --kind 30301 -d "$BOARD_ID" $RELAY) + echo "✓ Board query result:" + echo "$BOARD_QUERY" +fi +echo "" +echo "Step 4: Check if cards exist, create if missing" +if [ $(yq eval -r '.nostr.board.cards_created' $config || echo "null") != "null" ]; then + echo "✓ Using existing cards" +else + # echo "Creating sample cards..." + + # echo "Creating 'Task 1: Setup Database' card..." + CARD1_RESULT=$(retry_command "$NAK kanban create-card \ + --title \"Task 1: Setup Database\" \ + --description \"Initialize PostgreSQL database with required schemas\" \ + --board-id \"$BOARD_ID\" \ + --board-pubkey \"$BOARD_PUBKEY\" \ + --column \"Ideas\" \ + --priority \"high\" \ + --sec \"$NSEC\" \ + --relay \"$RELAY\"") + + if [ $? -ne 0 ]; then + echo "❌ Failed to create Task 1 card after retries" + exit 1 + fi + + echo "✓ Task 1 created" + [ "$DEBUG_MODE" = true ] && echo "Task 1 creation result: $CARD1_RESULT" + + #echo "Creating 'Task 2: Create API Endpoints' card..." + CARD2_RESULT=$(retry_command "$NAK kanban create-card \ + --title \"Task 2: Create API Endpoints\" \ + --description \"Develop REST API endpoints for user management\" \ + --board-id \"$BOARD_ID\" \ + --board-pubkey \"$BOARD_PUBKEY\" \ + --column \"Ideas\" \ + --priority \"medium\" \ + --sec \"$NSEC\" \ + --relay \"$RELAY\"") + + if [ $? -ne 0 ]; then + echo "❌ Failed to create Task 2 card after retries" + exit 1 + fi + + echo "✓ Task 2 created" + [ "$DEBUG_MODE" = true ] && echo "Task 2 creation result: $CARD2_RESULT" + + echo "✓ Card creation completed" + + # Mark cards as created in config + yq e '.nostr.board.cards_created = true' -i $config +fi + +echo "" +echo "Step 5: Verify cards (debug only)" +if [ "$DEBUG_MODE" = true ]; then + echo "Querying for card events..." + CARD_QUERY=$($NAK req --kind 30302 --author "$BOARD_PUBKEY" --limit 10 $RELAY) + echo "✓ Card query result:" + echo "$CARD_QUERY" +fi +echo "" +echo "Step 6: List cards on board" +BOARD_INFO=$($NAK kanban board-info \ + --board-id "$BOARD_ID" \ + --board-pubkey "$BOARD_PUBKEY" \ + --relay "$RELAY") +if [ "$DEBUG_MODE" = true ]; then + echo "$BOARD_INFO" | grep -E "(Board:|Description:|Board URL:)" || echo "$BOARD_INFO" +fi +CARD_LIST=$($NAK kanban list-cards \ + --board-id "$BOARD_ID" \ + --board-pubkey "$BOARD_PUBKEY" \ + --limit 10 \ + --relay "$RELAY") + +if echo "$CARD_LIST" | grep -q "Found [0-9] cards:"; then + [ "$DEBUG_MODE" = true ] && echo "$CARD_LIST" +else + echo "No cards found or error listing cards" +fi + +echo "" +echo "Step 7: Move a card to In Progress" +KANBANSTR_URL="https://www.kanbanstr.com/#/board/$BOARD_PUBKEY/$BOARD_ID" +echo "📋 View board: $KANBANSTR_URL" +read -p "Press Enter to move 'Task 2: Create API Endpoints' to 'In Progress' (or Ctrl+C to cancel)..." + +echo "Moving card..." +MOVE_RESULT=$(retry_command "$NAK kanban move-card \ + --card-title \"Task 2: Create API Endpoints\" \ + --board-id \"$BOARD_ID\" \ + --board-pubkey \"$BOARD_PUBKEY\" \ + --new-column \"In Progress\" \ + --sec \"$NSEC\" \ + --relay \"$RELAY\"") +echo here +if [ $? -ne 0 ]; then + echo "⚠️ Card move failed after retries" +else + echo "✓ Card moved successfully" +fi +[ "$DEBUG_MODE" = true ] && echo "Move result: $MOVE_RESULT" +echo "" +echo "Step 8: Verify card movement" +UPDATED_CARDS=$($NAK kanban list-cards \ + --board-id "$BOARD_ID" \ + --board-pubkey "$BOARD_PUBKEY" \ + --limit 10 \ + --relay "$RELAY") + +if echo "$UPDATED_CARDS" | grep -q "Found [0-9] cards:"; then + [ "$DEBUG_MODE" = true ] && echo "$UPDATED_CARDS" +else + echo "No cards found or error listing cards" +fi + +# Generate URLs for debugging +if [ "$DEBUG_MODE" = true ]; then + echo "" + echo "=== DEBUG URLS ===" + + # Board Highlighter URL + BOARD_NADDR=$($NAK encode naddr --kind 30301 --pubkey "$BOARD_PUBKEY" --identifier "$BOARD_ID") + echo "Board Highlighter: https://highlighter.com/a/$BOARD_NADDR" + + # Card Highlighter URLs + echo "" + echo "=== INDIVIDUAL CARD URLS ===" + + TASK1_EVENT_ID=$(echo "$CARD1_RESULT" | grep "Event ID:" | sed 's/.*Event ID: id:://' | awk '{print $1}') + if [ -n "$TASK1_EVENT_ID" ]; then + echo "Task 1 Card (Event ID: $TASK1_EVENT_ID):" + NEVENT=$($NAK encode nevent "$TASK1_EVENT_ID" --author "$BOARD_PUBKEY" 2>/dev/null || echo "") + if [ -n "$NEVENT" ]; then + echo "https://highlighter.com/a/$NEVENT" + else + echo "Failed to generate Highlighter URL for Task 1" + fi + fi + + echo "" + + TASK2_EVENT_ID=$(echo "$CARD2_RESULT" | grep "Event ID:" | sed 's/.*Event ID: id:://' | awk '{print $1}') + if [ -n "$TASK2_EVENT_ID" ]; then + echo "Task 2 Card (Event ID: $TASK2_EVENT_ID):" + NEVENT=$($NAK encode nevent "$TASK2_EVENT_ID" --author "$BOARD_PUBKEY" 2>/dev/null || echo "") + if [ -n "$NEVENT" ]; then + echo "https://highlighter.com/a/$NEVENT" + else + echo "Failed to generate Highlighter URL for Task 2" + fi + fi + + echo "" + + MOVED_EVENT_ID=$(echo "$MOVE_RESULT" | grep "Event ID:" | sed 's/.*Event ID: id:://' | awk '{print $1}') + if [ -n "$MOVED_EVENT_ID" ]; then + echo "Moved Card (Event ID: $MOVED_EVENT_ID):" + NEVENT=$($NAK encode nevent "$MOVED_EVENT_ID" --author "$BOARD_PUBKEY" 2>/dev/null || echo "") + if [ -n "$NEVENT" ]; then + echo "https://highlighter.com/a/$NEVENT" + else + echo "Failed to generate Highlighter URL for moved card" + fi + fi +fi + +echo "" +echo "=== SUMMARY ===" +echo "✓ Kanban workflow completed" +echo "✓ Board ID: $BOARD_ID" +echo "✓ Board URL: $KANBANSTR_URL" +echo "✓ Cards created and moved" +if [ "$DEBUG_MODE" = true ]; then + echo "✓ Debug mode: verbose output enabled" +else + echo "✓ Run with --debug for detailed output" +fi +echo "" +echo "All operations completed successfully!"