Kaynağa Gözat

add arp_cli

david 6 gün önce
ebeveyn
işleme
84f27bbf15

+ 293 - 0
arp_cli/client/graphql.go

@@ -0,0 +1,293 @@
+package client
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+// Client is a GraphQL client for the ARP API
+type Client struct {
+	baseURL    string
+	httpClient *http.Client
+	token      string
+}
+
+// New creates a new GraphQL client
+func New(baseURL string) *Client {
+	return &Client{
+		baseURL: baseURL,
+		httpClient: &http.Client{
+			Timeout: 30 * time.Second,
+		},
+	}
+}
+
+// SetToken sets the authentication token
+func (c *Client) SetToken(token string) {
+	c.token = token
+}
+
+// GraphQLRequest represents a GraphQL request
+type GraphQLRequest struct {
+	Query         string                 `json:"query"`
+	Variables     map[string]interface{} `json:"variables,omitempty"`
+	OperationName string                 `json:"operationName,omitempty"`
+}
+
+// GraphQLResponse represents a GraphQL response
+type GraphQLResponse struct {
+	Data   json.RawMessage `json:"data,omitempty"`
+	Errors []GraphQLError  `json:"errors,omitempty"`
+}
+
+// GraphQLError represents a GraphQL error
+type GraphQLError struct {
+	Message   string `json:"message"`
+	Locations []struct {
+		Line   int `json:"line"`
+		Column int `json:"column"`
+	} `json:"locations,omitempty"`
+	Path []interface{} `json:"path,omitempty"`
+}
+
+// Error returns the error message
+func (e GraphQLError) Error() string {
+	return e.Message
+}
+
+// Query executes a GraphQL query
+func (c *Client) Query(query string, variables map[string]interface{}) (*GraphQLResponse, error) {
+	return c.doRequest(query, variables, "")
+}
+
+// Mutation executes a GraphQL mutation
+func (c *Client) Mutation(query string, variables map[string]interface{}) (*GraphQLResponse, error) {
+	return c.doRequest(query, variables, "")
+}
+
+func (c *Client) doRequest(query string, variables map[string]interface{}, operationName string) (*GraphQLResponse, error) {
+	reqBody := GraphQLRequest{
+		Query:         query,
+		Variables:     variables,
+		OperationName: operationName,
+	}
+
+	bodyBytes, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal request: %w", err)
+	}
+
+	req, err := http.NewRequest("POST", c.baseURL, bytes.NewReader(bodyBytes))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	if c.token != "" {
+		req.Header.Set("Authorization", "Bearer "+c.token)
+	}
+
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response: %w", err)
+	}
+
+	var gqlResp GraphQLResponse
+	if err := json.Unmarshal(body, &gqlResp); err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	if len(gqlResp.Errors) > 0 {
+		return &gqlResp, fmt.Errorf("GraphQL error: %s", gqlResp.Errors[0].Message)
+	}
+
+	return &gqlResp, nil
+}
+
+// WebSocketClient handles GraphQL subscriptions
+type WebSocketClient struct {
+	conn     *websocket.Conn
+	baseURL  string
+	token    string
+	done     chan struct{}
+	messages chan json.RawMessage
+	errors   chan error
+}
+
+// NewWebSocketClient creates a new WebSocket client for subscriptions
+func NewWebSocketClient(baseURL string, token string) *WebSocketClient {
+	// Convert HTTP URL to WebSocket URL
+	wsURL := strings.Replace(baseURL, "http://", "ws://", 1)
+	wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
+
+	return &WebSocketClient{
+		baseURL:  wsURL,
+		token:    token,
+		done:     make(chan struct{}),
+		messages: make(chan json.RawMessage, 100),
+		errors:   make(chan error, 10),
+	}
+}
+
+// Connect establishes a WebSocket connection
+func (w *WebSocketClient) Connect() error {
+	u, err := url.Parse(w.baseURL)
+	if err != nil {
+		return fmt.Errorf("failed to parse URL: %w", err)
+	}
+
+	headers := http.Header{}
+	if w.token != "" {
+		headers.Set("Authorization", "Bearer "+w.token)
+	}
+
+	dialer := websocket.DefaultDialer
+	conn, _, err := dialer.Dial(u.String(), headers)
+	if err != nil {
+		return fmt.Errorf("failed to connect: %w", err)
+	}
+	w.conn = conn
+
+	// Send connection init
+	initMsg := map[string]interface{}{
+		"type": "connection_init",
+		"payload": map[string]interface{}{
+			"Authorization": "Bearer " + w.token,
+		},
+	}
+	if err := w.conn.WriteJSON(initMsg); err != nil {
+		return fmt.Errorf("failed to send init: %w", err)
+	}
+
+	// Wait for connection_ack
+	_, msg, err := w.conn.ReadMessage()
+	if err != nil {
+		return fmt.Errorf("failed to read ack: %w", err)
+	}
+
+	var ack map[string]interface{}
+	if err := json.Unmarshal(msg, &ack); err != nil {
+		return fmt.Errorf("failed to parse ack: %w", err)
+	}
+
+	if ack["type"] != "connection_ack" {
+		return fmt.Errorf("expected connection_ack, got: %s", ack["type"])
+	}
+
+	// Start reading messages
+	go w.readMessages()
+
+	return nil
+}
+
+// Subscribe starts a subscription
+func (w *WebSocketClient) Subscribe(id string, query string, variables map[string]interface{}) error {
+	subMsg := map[string]interface{}{
+		"id":   id,
+		"type": "start",
+		"payload": map[string]interface{}{
+			"query":     query,
+			"variables": variables,
+		},
+	}
+	return w.conn.WriteJSON(subMsg)
+}
+
+// Unsubscribe stops a subscription
+func (w *WebSocketClient) Unsubscribe(id string) error {
+	stopMsg := map[string]interface{}{
+		"id":   id,
+		"type": "stop",
+	}
+	return w.conn.WriteJSON(stopMsg)
+}
+
+// Messages returns the message channel
+func (w *WebSocketClient) Messages() <-chan json.RawMessage {
+	return w.messages
+}
+
+// Errors returns the error channel
+func (w *WebSocketClient) Errors() <-chan error {
+	return w.errors
+}
+
+// Done returns the done channel
+func (w *WebSocketClient) Done() <-chan struct{} {
+	return w.done
+}
+
+func (w *WebSocketClient) readMessages() {
+	defer close(w.done)
+	defer close(w.messages)
+	defer close(w.errors)
+
+	for {
+		_, msg, err := w.conn.ReadMessage()
+		if err != nil {
+			select {
+			case w.errors <- err:
+			default:
+			}
+			return
+		}
+
+		var parsed map[string]interface{}
+		if err := json.Unmarshal(msg, &parsed); err != nil {
+			continue
+		}
+
+		msgType, ok := parsed["type"].(string)
+		if !ok {
+			continue
+		}
+
+		switch msgType {
+		case "data":
+			if payload, ok := parsed["payload"].(map[string]interface{}); ok {
+				if data, ok := payload["data"]; ok {
+					dataBytes, _ := json.Marshal(data)
+					select {
+					case w.messages <- dataBytes:
+					default:
+					}
+				}
+				if errs, ok := payload["errors"].([]interface{}); ok && len(errs) > 0 {
+					if errBytes, err := json.Marshal(errs); err == nil {
+						w.errors <- fmt.Errorf("subscription error: %s", string(errBytes))
+					}
+				}
+			}
+		case "complete":
+			return
+		case "error":
+			if payload, ok := parsed["payload"].([]interface{}); ok {
+				if errBytes, err := json.Marshal(payload); err == nil {
+					w.errors <- fmt.Errorf("subscription error: %s", string(errBytes))
+				}
+			}
+		}
+	}
+}
+
+// Close closes the WebSocket connection
+func (w *WebSocketClient) Close() error {
+	if w.conn != nil {
+		return w.conn.Close()
+	}
+	return nil
+}

+ 374 - 0
arp_cli/cmd/channel.go

@@ -0,0 +1,374 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// ChannelCommand returns the channel command
+func ChannelCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "channel",
+		Usage: "Manage channels",
+		Description: `Manage ARP channels. Channels are chat conversations between users/agents.
+
+Use this command to create, list, update, and delete channels.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all channels",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: channelList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a channel by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Channel ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: channelGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new channel",
+				Action: channelCreate,
+				Flags: []cli.Flag{
+					&cli.StringSliceFlag{
+						Name:    "participants",
+						Aliases: []string{"p"},
+						Usage:   "Participant user IDs",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a channel",
+				Action: channelUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Channel ID",
+						Required: true,
+					},
+					&cli.StringSliceFlag{
+						Name:    "participants",
+						Aliases: []string{"p"},
+						Usage:   "Participant user IDs",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a channel",
+				Action: channelDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Channel ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+		},
+	}
+}
+
+type Channel struct {
+	ID           string `json:"id"`
+	Participants []struct {
+		ID    string `json:"id"`
+		Email string `json:"email"`
+	} `json:"participants"`
+	CreatedAt string `json:"createdAt"`
+	UpdatedAt string `json:"updatedAt"`
+}
+
+func channelList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Channels { channels { id participants { id email } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Channels []Channel `json:"channels"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Channels)
+	}
+
+	if len(result.Channels) == 0 {
+		fmt.Println("No channels found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Participants", "Created At"})
+	
+
+	for _, ch := range result.Channels {
+		participants := make([]string, len(ch.Participants))
+		for i, p := range ch.Participants {
+			participants[i] = p.Email
+		}
+		table.Append([]string{ch.ID, strings.Join(participants, ", "), ch.CreatedAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func channelGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Channel($id: ID!) { channel(id: $id) { id participants { id email } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Channel *Channel `json:"channel"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Channel == nil {
+		return fmt.Errorf("channel not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Channel)
+	}
+
+	ch := result.Channel
+	fmt.Printf("ID: %s\n", ch.ID)
+	fmt.Printf("Participants:\n")
+	for _, p := range ch.Participants {
+		fmt.Printf("  - %s (%s)\n", p.Email, p.ID)
+	}
+	fmt.Printf("Created At: %s\n", ch.CreatedAt)
+	fmt.Printf("Updated At: %s\n", ch.UpdatedAt)
+
+	return nil
+}
+
+func channelCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	participants := cmd.StringSlice("participants")
+
+	if len(participants) == 0 {
+		var participantsStr string
+		prompt := &survey.Input{Message: "Participant user IDs (comma-separated):"}
+		if err := survey.AskOne(prompt, &participantsStr, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+		for _, p := range strings.Split(participantsStr, ",") {
+			participants = append(participants, strings.TrimSpace(p))
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateChannel($input: NewChannel!) { createChannel(input: $input) { id participants { id email } createdAt updatedAt } }`
+
+	input := map[string]interface{}{
+		"participants": participants,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateChannel *Channel `json:"createChannel"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateChannel == nil {
+		return fmt.Errorf("failed to create channel")
+	}
+
+	fmt.Printf("Channel created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateChannel.ID)
+
+	return nil
+}
+
+func channelUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	participants := cmd.StringSlice("participants")
+
+	if len(participants) == 0 {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := map[string]interface{}{
+		"participants": participants,
+	}
+
+	mutation := `mutation UpdateChannel($id: ID!, $input: UpdateChannelInput!) { updateChannel(id: $id, input: $input) { id participants { id email } createdAt updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateChannel *Channel `json:"updateChannel"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateChannel == nil {
+		return fmt.Errorf("channel not found")
+	}
+
+	fmt.Printf("Channel updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateChannel.ID)
+
+	return nil
+}
+
+func channelDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete channel %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteChannel($id: ID!) { deleteChannel(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteChannel bool `json:"deleteChannel"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteChannel {
+		fmt.Printf("Channel %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete channel %s.\n", id)
+	}
+
+	return nil
+}

+ 152 - 0
arp_cli/cmd/config.go

@@ -0,0 +1,152 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/urfave/cli/v3"
+)
+
+// ConfigCommand returns the config command
+func ConfigCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "config",
+		Usage: "Manage CLI configuration",
+		Description: `View and manage the arp_cli configuration.
+
+The configuration is stored in ~/.arp_cli/config.json and contains:
+- Server URL
+- Authentication token
+- Logged-in user email`,
+		Commands: []*cli.Command{
+			{
+				Name:   "view",
+				Usage:  "Show current configuration",
+				Action: configView,
+			},
+			{
+				Name:   "set-url",
+				Usage:  "Set the server URL",
+				Action: configSetURL,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "url",
+						Aliases:  []string{"u"},
+						Usage:    "Server URL",
+						Required: true,
+					},
+				},
+			},
+			{
+				Name:    "logout",
+				Usage:   "Clear stored authentication token",
+				Aliases: []string{"clear"},
+				Action:  configLogout,
+			},
+		},
+		Action: configView,
+	}
+}
+
+func configView(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return fmt.Errorf("failed to load config: %w", err)
+	}
+
+	fmt.Println("Current Configuration:")
+	fmt.Printf("  Server URL: %s\n", cfg.ServerURL)
+	if cfg.UserEmail != "" {
+		fmt.Printf("  Logged in as: %s\n", cfg.UserEmail)
+	} else {
+		fmt.Println("  Logged in as: (not authenticated)")
+	}
+
+	if cfg.Token != "" {
+		// Show first/last few characters of token
+		if len(cfg.Token) > 20 {
+			fmt.Printf("  Token: %s...%s\n", cfg.Token[:10], cfg.Token[len(cfg.Token)-6:])
+		} else {
+			fmt.Println("  Token: (set)")
+		}
+	} else {
+		fmt.Println("  Token: (not set)")
+	}
+
+	return nil
+}
+
+func configSetURL(ctx context.Context, cmd *cli.Command) error {
+	url := cmd.String("url")
+
+	cfg, err := config.Load()
+	if err != nil {
+		return fmt.Errorf("failed to load config: %w", err)
+	}
+
+	// If user is logged in, warn them
+	if cfg.Token != "" {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: "Changing the server URL will clear your authentication token. Continue?",
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Aborted.")
+			return nil
+		}
+		cfg.Token = ""
+		cfg.UserEmail = ""
+	}
+
+	cfg.ServerURL = url
+	if err := config.Save(cfg); err != nil {
+		return fmt.Errorf("failed to save config: %w", err)
+	}
+
+	fmt.Printf("Server URL set to: %s\n", url)
+	if cfg.Token == "" {
+		fmt.Println("Please run 'arp_cli login' to authenticate with the new server.")
+	}
+
+	return nil
+}
+
+func configLogout(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return fmt.Errorf("failed to load config: %w", err)
+	}
+
+	if cfg.Token == "" {
+		fmt.Println("No authentication token stored.")
+		return nil
+	}
+
+	confirm := false
+	prompt := &survey.Confirm{
+		Message: "Are you sure you want to logout?",
+		Default: true,
+	}
+	if err := survey.AskOne(prompt, &confirm); err != nil {
+		return err
+	}
+
+	if !confirm {
+		fmt.Println("Aborted.")
+		return nil
+	}
+
+	if err := config.Clear(); err != nil {
+		return fmt.Errorf("failed to clear config: %w", err)
+	}
+
+	fmt.Println("Logged out successfully.")
+	return nil
+}

+ 172 - 0
arp_cli/cmd/login.go

@@ -0,0 +1,172 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/urfave/cli/v3"
+)
+
+// LoginCommand returns the login command
+func LoginCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "login",
+		Usage: "Authenticate with the ARP server",
+		Description: `Login to an ARP server using email and password.
+
+This command will store the authentication token in ~/.arp_cli/config.json
+for use by subsequent commands.
+
+Examples:
+  # Login with URL and email specified
+  arp_cli login --url http://localhost:8080/query --email user@example.com
+
+  # Login interactively (will prompt for all fields)
+  arp_cli login`,
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:    "url",
+				Aliases: []string{"u"},
+				Usage:   "ARP server URL",
+				Sources: cli.EnvVars("ARP_URL"),
+			},
+			&cli.StringFlag{
+				Name:    "email",
+				Aliases: []string{"e"},
+				Usage:   "User email address",
+			},
+			&cli.StringFlag{
+				Name:    "password",
+				Aliases: []string{"p"},
+				Usage:   "User password (will prompt if not provided)",
+			},
+		},
+		Action: doLogin,
+	}
+}
+
+func doLogin(ctx context.Context, cmd *cli.Command) error {
+	// Get URL
+	serverURL := cmd.String("url")
+	if serverURL == "" {
+		cfg, err := config.Load()
+		if err != nil {
+			return fmt.Errorf("failed to load config: %w", err)
+		}
+		serverURL = cfg.ServerURL
+	}
+
+	// Prompt for URL if still empty
+	if serverURL == "" {
+		prompt := &survey.Input{
+			Message: "Server URL",
+			Help:    "The GraphQL endpoint URL (e.g., http://localhost:8080/query)",
+		}
+		if err := survey.AskOne(prompt, &serverURL, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	// Get email
+	email := cmd.String("email")
+	if email == "" {
+		prompt := &survey.Input{
+			Message: "Email",
+			Help:    "Your user account email address",
+		}
+		if err := survey.AskOne(prompt, &email, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	// Get password
+	password := cmd.String("password")
+	if password == "" {
+		prompt := &survey.Password{
+			Message: "Password",
+			Help:    "Your user account password",
+		}
+		if err := survey.AskOne(prompt, &password, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	// Perform login
+	c := client.New(serverURL)
+
+	query := `
+		mutation Login($email: String!, $password: String!) {
+			login(email: $email, password: $password) {
+				token
+				user {
+					id
+					email
+					roles {
+						id
+						name
+					}
+				}
+			}
+		}`
+
+	variables := map[string]interface{}{
+		"email":    email,
+		"password": password,
+	}
+
+	resp, err := c.Mutation(query, variables)
+	if err != nil {
+		return fmt.Errorf("login failed: %w", err)
+	}
+
+	// Parse response
+	var loginResp struct {
+		Login struct {
+			Token string `json:"token"`
+			User  struct {
+				ID    string `json:"id"`
+				Email string `json:"email"`
+				Roles []struct {
+					ID   string `json:"id"`
+					Name string `json:"name"`
+				} `json:"roles"`
+			} `json:"user"`
+		} `json:"login"`
+	}
+
+	if err := json.Unmarshal(resp.Data, &loginResp); err != nil {
+		return fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	// Save config
+	cfg, err := config.Load()
+	if err != nil {
+		return fmt.Errorf("failed to load config: %w", err)
+	}
+	cfg.ServerURL = serverURL
+	cfg.Token = loginResp.Login.Token
+	cfg.UserEmail = loginResp.Login.User.Email
+
+	if err := config.Save(cfg); err != nil {
+		return fmt.Errorf("failed to save config: %w", err)
+	}
+
+	fmt.Printf("Logged in as %s\n", loginResp.Login.User.Email)
+	if len(loginResp.Login.User.Roles) > 0 {
+		fmt.Print("Roles: ")
+		for i, role := range loginResp.Login.User.Roles {
+			if i > 0 {
+				fmt.Print(", ")
+			}
+			fmt.Print(role.Name)
+		}
+		fmt.Println()
+	}
+
+	return nil
+}

+ 452 - 0
arp_cli/cmd/message.go

@@ -0,0 +1,452 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// MessageCommand returns the message command
+func MessageCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "message",
+		Usage: "Manage messages",
+		Description: `Manage ARP messages. Messages are sent in channels between users/agents.
+
+Use this command to create, list, update, and delete messages. You can also watch for real-time message updates.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all messages",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: messageList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a message by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Message ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: messageGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new message",
+				Action: messageCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "conversation",
+						Aliases: []string{"c"},
+						Usage:   "Conversation/Channel ID",
+					},
+					&cli.StringFlag{
+						Name:    "sender",
+						Aliases: []string{"s"},
+						Usage:   "Sender user ID",
+					},
+					&cli.StringFlag{
+						Name:    "content",
+						Aliases: []string{"m"},
+						Usage:   "Message content",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a message",
+				Action: messageUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Message ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "content",
+						Aliases: []string{"c"},
+						Usage:   "Message content",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a message",
+				Action: messageDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Message ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+			{
+				Name:   "watch",
+				Usage:  "Watch for real-time message updates",
+				Action: messageWatch,
+			},
+		},
+	}
+}
+
+type Message struct {
+	ID             string `json:"id"`
+	ConversationID string `json:"conversationId"`
+	SenderID       string `json:"senderId"`
+	Sender         *User  `json:"sender"`
+	Content        string `json:"content"`
+	SentAt         string `json:"sentAt"`
+	CreatedAt      string `json:"createdAt"`
+	UpdatedAt      string `json:"updatedAt"`
+}
+
+func messageList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Messages { messages { id conversationId sender { id email } content sentAt createdAt } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Messages []Message `json:"messages"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Messages)
+	}
+
+	if len(result.Messages) == 0 {
+		fmt.Println("No messages found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Conversation", "Sender", "Content", "Sent At"})
+	
+
+	for _, m := range result.Messages {
+		sender := ""
+		if m.Sender != nil {
+			sender = m.Sender.Email
+		}
+		content := m.Content
+		if len(content) > 50 {
+			content = content[:47] + "..."
+		}
+		table.Append([]string{m.ID, m.ConversationID, sender, content, m.SentAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func messageGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Message($id: ID!) { message(id: $id) { id conversationId sender { id email } content sentAt createdAt updatedAt } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Message *Message `json:"message"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Message == nil {
+		return fmt.Errorf("message not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Message)
+	}
+
+	m := result.Message
+	fmt.Printf("ID: %s\n", m.ID)
+	fmt.Printf("Conversation ID: %s\n", m.ConversationID)
+	if m.Sender != nil {
+		fmt.Printf("Sender: %s (%s)\n", m.Sender.Email, m.SenderID)
+	} else {
+		fmt.Printf("Sender ID: %s\n", m.SenderID)
+	}
+	fmt.Printf("Content: %s\n", m.Content)
+	fmt.Printf("Sent At: %s\n", m.SentAt)
+	fmt.Printf("Created At: %s\n", m.CreatedAt)
+	fmt.Printf("Updated At: %s\n", m.UpdatedAt)
+
+	return nil
+}
+
+func messageCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	conversationID := cmd.String("conversation")
+	senderID := cmd.String("sender")
+	content := cmd.String("content")
+
+	if conversationID == "" {
+		prompt := &survey.Input{Message: "Conversation ID:"}
+		if err := survey.AskOne(prompt, &conversationID, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if senderID == "" {
+		prompt := &survey.Input{Message: "Sender user ID:"}
+		if err := survey.AskOne(prompt, &senderID, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if content == "" {
+		prompt := &survey.Multiline{Message: "Message content:"}
+		if err := survey.AskOne(prompt, &content, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateMessage($input: NewMessage!) { createMessage(input: $input) { id conversationId sender { id email } content sentAt createdAt } }`
+
+	input := map[string]interface{}{
+		"conversationId": conversationID,
+		"senderId":       senderID,
+		"content":        content,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateMessage *Message `json:"createMessage"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateMessage == nil {
+		return fmt.Errorf("failed to create message")
+	}
+
+	fmt.Printf("Message created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateMessage.ID)
+
+	return nil
+}
+
+func messageUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	content := cmd.String("content")
+
+	if content == "" {
+		prompt := &survey.Multiline{Message: "New message content:"}
+		if err := survey.AskOne(prompt, &content, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := map[string]interface{}{
+		"content": content,
+	}
+
+	mutation := `mutation UpdateMessage($id: ID!, $input: UpdateMessageInput!) { updateMessage(id: $id, input: $input) { id content updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateMessage *Message `json:"updateMessage"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateMessage == nil {
+		return fmt.Errorf("message not found")
+	}
+
+	fmt.Printf("Message updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateMessage.ID)
+
+	return nil
+}
+
+func messageDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete message %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteMessage($id: ID!) { deleteMessage(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteMessage bool `json:"deleteMessage"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteMessage {
+		fmt.Printf("Message %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete message %s.\n", id)
+	}
+
+	return nil
+}
+
+func messageWatch(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	wsClient := client.NewWebSocketClient(cfg.ServerURL, cfg.Token)
+
+	if err := wsClient.Connect(); err != nil {
+		return fmt.Errorf("failed to connect: %w", err)
+	}
+	defer wsClient.Close()
+
+	fmt.Println("Watching for new messages...")
+	fmt.Println("Press Ctrl+C to stop.")
+
+	subscription := "subscription { messageAdded { id conversationId sender { id email } content sentAt } }"
+
+	if err := wsClient.Subscribe("1", subscription, nil); err != nil {
+		return fmt.Errorf("failed to subscribe: %w", err)
+	}
+
+	for {
+		select {
+		case msg := <-wsClient.Messages():
+			fmt.Printf("New message: %s\n", string(msg))
+		case err := <-wsClient.Errors():
+			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		case <-wsClient.Done():
+			return nil
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}

+ 451 - 0
arp_cli/cmd/note.go

@@ -0,0 +1,451 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// NoteCommand returns the note command
+func NoteCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "note",
+		Usage: "Manage notes",
+		Description: `Manage ARP notes. Notes are text entries belonging to users and associated with services.
+
+Use this command to create, list, update, and delete notes.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all notes",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: noteList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a note by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Note ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: noteGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new note",
+				Action: noteCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "title",
+						Aliases: []string{"t"},
+						Usage:   "Note title",
+					},
+					&cli.StringFlag{
+						Name:    "content",
+						Aliases: []string{"c"},
+						Usage:   "Note content",
+					},
+					&cli.StringFlag{
+						Name:    "user",
+						Aliases: []string{"u"},
+						Usage:   "User ID",
+					},
+					&cli.StringFlag{
+						Name:    "service",
+						Aliases: []string{"s"},
+						Usage:   "Service ID",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a note",
+				Action: noteUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Note ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "title",
+						Aliases: []string{"t"},
+						Usage:   "Note title",
+					},
+					&cli.StringFlag{
+						Name:    "content",
+						Aliases: []string{"c"},
+						Usage:   "Note content",
+					},
+					&cli.StringFlag{
+						Name:    "user",
+						Aliases: []string{"u"},
+						Usage:   "User ID",
+					},
+					&cli.StringFlag{
+						Name:    "service",
+						Aliases: []string{"s"},
+						Usage:   "Service ID",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a note",
+				Action: noteDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Note ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+		},
+	}
+}
+
+type Note struct {
+	ID        string   `json:"id"`
+	Title     string   `json:"title"`
+	Content   string   `json:"content"`
+	UserID    string   `json:"userId"`
+	User      *User    `json:"user"`
+	ServiceID string   `json:"serviceId"`
+	Service   *Service `json:"service"`
+	CreatedAt string   `json:"createdAt"`
+	UpdatedAt string   `json:"updatedAt"`
+}
+
+func noteList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Notes { notes { id title content userId user { id email } serviceId createdAt updatedAt } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Notes []Note `json:"notes"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Notes)
+	}
+
+	if len(result.Notes) == 0 {
+		fmt.Println("No notes found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Title", "User", "Service ID", "Created At"})
+	
+
+	for _, n := range result.Notes {
+		userEmail := ""
+		if n.User != nil {
+			userEmail = n.User.Email
+		}
+		table.Append([]string{n.ID, n.Title, userEmail, n.ServiceID, n.CreatedAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func noteGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Note($id: ID!) { note(id: $id) { id title content userId user { id email } serviceId service { id name } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Note *Note `json:"note"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Note == nil {
+		return fmt.Errorf("note not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Note)
+	}
+
+	n := result.Note
+	fmt.Printf("ID: %s\n", n.ID)
+	fmt.Printf("Title: %s\n", n.Title)
+	fmt.Printf("Content: %s\n", n.Content)
+	if n.User != nil {
+		fmt.Printf("User: %s (%s)\n", n.User.Email, n.UserID)
+	} else {
+		fmt.Printf("User ID: %s\n", n.UserID)
+	}
+	if n.Service != nil {
+		fmt.Printf("Service: %s (%s)\n", n.Service.Name, n.ServiceID)
+	} else {
+		fmt.Printf("Service ID: %s\n", n.ServiceID)
+	}
+	fmt.Printf("Created At: %s\n", n.CreatedAt)
+	fmt.Printf("Updated At: %s\n", n.UpdatedAt)
+
+	return nil
+}
+
+func noteCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	title := cmd.String("title")
+	content := cmd.String("content")
+	userID := cmd.String("user")
+	serviceID := cmd.String("service")
+
+	if title == "" {
+		prompt := &survey.Input{Message: "Title:"}
+		if err := survey.AskOne(prompt, &title, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if content == "" {
+		prompt := &survey.Multiline{Message: "Content:"}
+		if err := survey.AskOne(prompt, &content, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if userID == "" {
+		prompt := &survey.Input{Message: "User ID:"}
+		if err := survey.AskOne(prompt, &userID, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if serviceID == "" {
+		prompt := &survey.Input{Message: "Service ID:"}
+		if err := survey.AskOne(prompt, &serviceID, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateNote($input: NewNote!) { createNote(input: $input) { id title content userId user { id email } serviceId createdAt updatedAt } }`
+
+	input := map[string]interface{}{
+		"title":     title,
+		"content":   content,
+		"userId":    userID,
+		"serviceId": serviceID,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateNote *Note `json:"createNote"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateNote == nil {
+		return fmt.Errorf("failed to create note")
+	}
+
+	fmt.Printf("Note created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateNote.ID)
+	fmt.Printf("Title: %s\n", result.CreateNote.Title)
+
+	return nil
+}
+
+func noteUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	title := cmd.String("title")
+	content := cmd.String("content")
+	userID := cmd.String("user")
+	serviceID := cmd.String("service")
+
+	if title == "" && content == "" && userID == "" && serviceID == "" {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := make(map[string]interface{})
+	if title != "" {
+		input["title"] = title
+	}
+	if content != "" {
+		input["content"] = content
+	}
+	if userID != "" {
+		input["userId"] = userID
+	}
+	if serviceID != "" {
+		input["serviceId"] = serviceID
+	}
+
+	mutation := `mutation UpdateNote($id: ID!, $input: UpdateNoteInput!) { updateNote(id: $id, input: $input) { id title content userId serviceId createdAt updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateNote *Note `json:"updateNote"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateNote == nil {
+		return fmt.Errorf("note not found")
+	}
+
+	fmt.Printf("Note updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateNote.ID)
+	fmt.Printf("Title: %s\n", result.UpdateNote.Title)
+
+	return nil
+}
+
+func noteDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete note %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteNote($id: ID!) { deleteNote(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteNote bool `json:"deleteNote"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteNote {
+		fmt.Printf("Note %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete note %s.\n", id)
+	}
+
+	return nil
+}

+ 383 - 0
arp_cli/cmd/permission.go

@@ -0,0 +1,383 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// PermissionCommand returns the permission command
+func PermissionCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "permission",
+		Usage: "Manage permissions",
+		Description: `Manage ARP permissions. Permissions define fine-grained access control.
+
+Use this command to create, list, update, and delete permissions.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all permissions",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: permissionList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a permission by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Permission ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: permissionGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new permission",
+				Action: permissionCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "code",
+						Aliases: []string{"c"},
+						Usage:   "Permission code",
+					},
+					&cli.StringFlag{
+						Name:    "description",
+						Aliases: []string{"d"},
+						Usage:   "Permission description",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a permission",
+				Action: permissionUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Permission ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "code",
+						Aliases: []string{"c"},
+						Usage:   "Permission code",
+					},
+					&cli.StringFlag{
+						Name:    "description",
+						Aliases: []string{"d"},
+						Usage:   "Permission description",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a permission",
+				Action: permissionDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Permission ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+		},
+	}
+}
+
+type PermissionDetail struct {
+	ID          string `json:"id"`
+	Code        string `json:"code"`
+	Description string `json:"description"`
+}
+
+func permissionList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Permissions { permissions { id code description } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Permissions []PermissionDetail `json:"permissions"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Permissions)
+	}
+
+	if len(result.Permissions) == 0 {
+		fmt.Println("No permissions found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Code", "Description"})
+	
+
+	for _, p := range result.Permissions {
+		table.Append([]string{p.ID, p.Code, p.Description})
+	}
+
+	table.Render()
+	return nil
+}
+
+func permissionGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Permission($id: ID!) { permission(id: $id) { id code description } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Permission *PermissionDetail `json:"permission"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Permission == nil {
+		return fmt.Errorf("permission not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Permission)
+	}
+
+	p := result.Permission
+	fmt.Printf("ID: %s\n", p.ID)
+	fmt.Printf("Code: %s\n", p.Code)
+	fmt.Printf("Description: %s\n", p.Description)
+
+	return nil
+}
+
+func permissionCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	code := cmd.String("code")
+	description := cmd.String("description")
+
+	if code == "" {
+		prompt := &survey.Input{Message: "Permission code:"}
+		if err := survey.AskOne(prompt, &code, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if description == "" {
+		prompt := &survey.Input{Message: "Description:"}
+		if err := survey.AskOne(prompt, &description, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreatePermission($input: NewPermission!) { createPermission(input: $input) { id code description } }`
+
+	input := map[string]interface{}{
+		"code":        code,
+		"description": description,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreatePermission *PermissionDetail `json:"createPermission"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreatePermission == nil {
+		return fmt.Errorf("failed to create permission")
+	}
+
+	fmt.Printf("Permission created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreatePermission.ID)
+	fmt.Printf("Code: %s\n", result.CreatePermission.Code)
+
+	return nil
+}
+
+func permissionUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	code := cmd.String("code")
+	description := cmd.String("description")
+
+	if code == "" && description == "" {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := make(map[string]interface{})
+	if code != "" {
+		input["code"] = code
+	}
+	if description != "" {
+		input["description"] = description
+	}
+
+	mutation := `mutation UpdatePermission($id: ID!, $input: UpdatePermissionInput!) { updatePermission(id: $id, input: $input) { id code description } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdatePermission *PermissionDetail `json:"updatePermission"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdatePermission == nil {
+		return fmt.Errorf("permission not found")
+	}
+
+	fmt.Printf("Permission updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdatePermission.ID)
+	fmt.Printf("Code: %s\n", result.UpdatePermission.Code)
+
+	return nil
+}
+
+func permissionDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete permission %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeletePermission($id: ID!) { deletePermission(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeletePermission bool `json:"deletePermission"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeletePermission {
+		fmt.Printf("Permission %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete permission %s.\n", id)
+	}
+
+	return nil
+}

+ 426 - 0
arp_cli/cmd/role.go

@@ -0,0 +1,426 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// RoleCommand returns the role command
+func RoleCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "role",
+		Usage: "Manage roles",
+		Description: `Manage ARP roles. Roles group permissions for access control.
+
+Use this command to create, list, update, and delete roles.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all roles",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: roleList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a role by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Role ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: roleGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new role",
+				Action: roleCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "name",
+						Aliases: []string{"n"},
+						Usage:   "Role name",
+					},
+					&cli.StringFlag{
+						Name:    "description",
+						Aliases: []string{"d"},
+						Usage:   "Role description",
+					},
+					&cli.StringSliceFlag{
+						Name:    "permissions",
+						Aliases: []string{"p"},
+						Usage:   "Permission IDs",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a role",
+				Action: roleUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Role ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "name",
+						Aliases: []string{"n"},
+						Usage:   "Role name",
+					},
+					&cli.StringFlag{
+						Name:    "description",
+						Aliases: []string{"d"},
+						Usage:   "Role description",
+					},
+					&cli.StringSliceFlag{
+						Name:    "permissions",
+						Aliases: []string{"p"},
+						Usage:   "Permission IDs",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a role",
+				Action: roleDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Role ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+		},
+	}
+}
+
+type RoleDetail struct {
+	ID          string       `json:"id"`
+	Name        string       `json:"name"`
+	Description string       `json:"description"`
+	Permissions []Permission `json:"permissions"`
+}
+
+type Permission struct {
+	ID          string `json:"id"`
+	Code        string `json:"code"`
+	Description string `json:"description"`
+}
+
+func roleList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Roles { roles { id name description permissions { id code } } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Roles []RoleDetail `json:"roles"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Roles)
+	}
+
+	if len(result.Roles) == 0 {
+		fmt.Println("No roles found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Name", "Description", "Permissions"})
+	
+
+	for _, r := range result.Roles {
+		perms := make([]string, len(r.Permissions))
+		for i, p := range r.Permissions {
+			perms[i] = p.Code
+		}
+		table.Append([]string{r.ID, r.Name, r.Description, strings.Join(perms, ", ")})
+	}
+
+	table.Render()
+	return nil
+}
+
+func roleGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Role($id: ID!) { role(id: $id) { id name description permissions { id code description } } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Role *RoleDetail `json:"role"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Role == nil {
+		return fmt.Errorf("role not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Role)
+	}
+
+	r := result.Role
+	fmt.Printf("ID: %s\n", r.ID)
+	fmt.Printf("Name: %s\n", r.Name)
+	fmt.Printf("Description: %s\n", r.Description)
+	fmt.Printf("Permissions:\n")
+	for _, p := range r.Permissions {
+		fmt.Printf("  - %s (%s): %s\n", p.Code, p.ID, p.Description)
+	}
+
+	return nil
+}
+
+func roleCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	name := cmd.String("name")
+	description := cmd.String("description")
+	permissions := cmd.StringSlice("permissions")
+
+	if name == "" {
+		prompt := &survey.Input{Message: "Role name:"}
+		if err := survey.AskOne(prompt, &name, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if description == "" {
+		prompt := &survey.Input{Message: "Description:"}
+		if err := survey.AskOne(prompt, &description, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if len(permissions) == 0 {
+		var permissionsStr string
+		prompt := &survey.Input{Message: "Permission IDs (comma-separated):"}
+		if err := survey.AskOne(prompt, &permissionsStr, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+		for _, p := range strings.Split(permissionsStr, ",") {
+			permissions = append(permissions, strings.TrimSpace(p))
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateRole($input: NewRole!) { createRole(input: $input) { id name description permissions { id code } } }`
+
+	input := map[string]interface{}{
+		"name":        name,
+		"description": description,
+		"permissions": permissions,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateRole *RoleDetail `json:"createRole"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateRole == nil {
+		return fmt.Errorf("failed to create role")
+	}
+
+	fmt.Printf("Role created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateRole.ID)
+	fmt.Printf("Name: %s\n", result.CreateRole.Name)
+
+	return nil
+}
+
+func roleUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	name := cmd.String("name")
+	description := cmd.String("description")
+	permissions := cmd.StringSlice("permissions")
+
+	if name == "" && description == "" && len(permissions) == 0 {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := make(map[string]interface{})
+	if name != "" {
+		input["name"] = name
+	}
+	if description != "" {
+		input["description"] = description
+	}
+	if len(permissions) > 0 {
+		input["permissions"] = permissions
+	}
+
+	mutation := `mutation UpdateRole($id: ID!, $input: UpdateRoleInput!) { updateRole(id: $id, input: $input) { id name description permissions { id code } } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateRole *RoleDetail `json:"updateRole"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateRole == nil {
+		return fmt.Errorf("role not found")
+	}
+
+	fmt.Printf("Role updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateRole.ID)
+	fmt.Printf("Name: %s\n", result.UpdateRole.Name)
+
+	return nil
+}
+
+func roleDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete role %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteRole($id: ID!) { deleteRole(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteRole bool `json:"deleteRole"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteRole {
+		fmt.Printf("Role %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete role %s.\n", id)
+	}
+
+	return nil
+}

+ 105 - 0
arp_cli/cmd/root.go

@@ -0,0 +1,105 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/urfave/cli/v3"
+)
+
+var (
+	// Global flags
+	urlFlag = &cli.StringFlag{
+		Name:    "url",
+		Aliases: []string{"u"},
+		Usage:   "ARP server URL",
+		Sources: cli.EnvVars("ARP_URL"),
+	}
+	outputFlag = &cli.StringFlag{
+		Name:    "output",
+		Aliases: []string{"o"},
+		Usage:   "Output format (table, json, quiet)",
+		Value:   "table",
+	}
+)
+
+// GetClient creates a GraphQL client with the configured URL and token
+func GetClient(ctx context.Context, cmd *cli.Command) (*client.Client, error) {
+	cfg, err := config.Load()
+	if err != nil {
+		return nil, fmt.Errorf("failed to load config: %w", err)
+	}
+
+	// Command flag takes precedence, then config, then empty
+	serverURL := cmd.String(urlFlag.Name)
+	if serverURL == "" {
+		serverURL = cfg.ServerURL
+	}
+	if serverURL == "" {
+		return nil, fmt.Errorf("no server URL configured. Use --url flag or run 'arp_cli login' first")
+	}
+
+	c := client.New(serverURL)
+	if cfg.Token != "" {
+		c.SetToken(cfg.Token)
+	}
+
+	return c, nil
+}
+
+// RequireAuth ensures the user is authenticated
+func RequireAuth(cfg *config.Config) error {
+	if cfg.Token == "" {
+		return fmt.Errorf("not authenticated. Run 'arp_cli login' first")
+	}
+	return nil
+}
+
+// RootCommand returns the root CLI command
+func RootCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "arp_cli",
+		Usage: "Command-line interface for ARP (Agent-native ERP) server",
+		Description: `arp_cli is a command-line tool for interacting with the ARP GraphQL API.
+
+It provides CRUD operations for managing users, services, tasks, notes, channels,
+messages, roles, and permissions. The CLI also supports real-time subscriptions
+for task and message events.
+
+Start by running 'arp_cli login' to authenticate with your ARP server.`,
+		Flags: []cli.Flag{
+			urlFlag,
+			outputFlag,
+		},
+		Commands: []*cli.Command{
+			LoginCommand(),
+			ConfigCommand(),
+			ServiceCommand(),
+			UserCommand(),
+			NoteCommand(),
+			TaskCommand(),
+			ChannelCommand(),
+			MessageCommand(),
+			RoleCommand(),
+			PermissionCommand(),
+		},
+		Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
+			// Set default output format in context if needed
+			return ctx, nil
+		},
+		Action: func(ctx context.Context, cmd *cli.Command) error {
+			// Show help when no subcommand is provided
+			return cli.ShowAppHelp(cmd)
+		},
+	}
+}
+
+// Run executes the CLI
+func Run() error {
+	cmd := RootCommand()
+	return cmd.Run(context.Background(), os.Args)
+}

+ 447 - 0
arp_cli/cmd/service.go

@@ -0,0 +1,447 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// ServiceCommand returns the service command
+func ServiceCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "service",
+		Usage: "Manage services",
+		Description: `Manage ARP services. Services are entities that agents coordinate work around.
+
+Services can have participants (users) and contain tasks. Use this command to
+create, list, update, and delete services.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all services",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: serviceList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a service by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Service ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: serviceGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new service",
+				Action: serviceCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "name",
+						Aliases: []string{"n"},
+						Usage:   "Service name",
+					},
+					&cli.StringFlag{
+						Name:    "description",
+						Aliases: []string{"d"},
+						Usage:   "Service description",
+					},
+					&cli.StringFlag{
+						Name:    "created-by",
+						Aliases: []string{"c"},
+						Usage:   "Creator user ID",
+					},
+					&cli.StringSliceFlag{
+						Name:    "participants",
+						Aliases: []string{"p"},
+						Usage:   "Participant user IDs",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a service",
+				Action: serviceUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Service ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "name",
+						Aliases: []string{"n"},
+						Usage:   "Service name",
+					},
+					&cli.StringFlag{
+						Name:    "description",
+						Aliases: []string{"d"},
+						Usage:   "Service description",
+					},
+					&cli.StringSliceFlag{
+						Name:    "participants",
+						Aliases: []string{"p"},
+						Usage:   "Participant user IDs",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a service",
+				Action: serviceDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Service ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+		},
+	}
+}
+
+type Service struct {
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+	CreatedByID string `json:"createdById"`
+	CreatedBy   struct {
+		ID    string `json:"id"`
+		Email string `json:"email"`
+	} `json:"createdBy"`
+	Participants []struct {
+		ID    string `json:"id"`
+		Email string `json:"email"`
+	} `json:"participants"`
+	CreatedAt string `json:"createdAt"`
+	UpdatedAt string `json:"updatedAt"`
+}
+
+func serviceList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Services { services { id name description createdBy { id email } participants { id email } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Services []Service `json:"services"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Services)
+	}
+
+	if len(result.Services) == 0 {
+		fmt.Println("No services found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Name", "Description", "Created By", "Participants", "Created At"})
+	
+
+	for _, s := range result.Services {
+		participants := fmt.Sprintf("%d", len(s.Participants))
+		table.Append([]string{s.ID, s.Name, s.Description, s.CreatedBy.Email, participants, s.CreatedAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func serviceGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Service($id: ID!) { service(id: $id) { id name description createdBy { id email } participants { id email } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Service *Service `json:"service"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Service == nil {
+		return fmt.Errorf("service not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Service)
+	}
+
+	s := result.Service
+	fmt.Printf("ID: %s\n", s.ID)
+	fmt.Printf("Name: %s\n", s.Name)
+	fmt.Printf("Description: %s\n", s.Description)
+	fmt.Printf("Created By: %s\n", s.CreatedBy.Email)
+	fmt.Printf("Participants:\n")
+	for _, p := range s.Participants {
+		fmt.Printf("  - %s (%s)\n", p.Email, p.ID)
+	}
+	fmt.Printf("Created At: %s\n", s.CreatedAt)
+	fmt.Printf("Updated At: %s\n", s.UpdatedAt)
+
+	return nil
+}
+
+func serviceCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	name := cmd.String("name")
+	description := cmd.String("description")
+	createdBy := cmd.String("created-by")
+	participants := cmd.StringSlice("participants")
+
+	if name == "" {
+		prompt := &survey.Input{Message: "Service name:"}
+		if err := survey.AskOne(prompt, &name, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if description == "" {
+		prompt := &survey.Input{Message: "Description (optional):"}
+		survey.AskOne(prompt, &description)
+	}
+
+	if createdBy == "" {
+		prompt := &survey.Input{Message: "Creator user ID:"}
+		if err := survey.AskOne(prompt, &createdBy, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if len(participants) == 0 {
+		var participantsStr string
+		prompt := &survey.Input{Message: "Participant user IDs (comma-separated, optional):"}
+		if err := survey.AskOne(prompt, &participantsStr); err != nil {
+			return err
+		}
+		if participantsStr != "" {
+			for _, p := range strings.Split(participantsStr, ",") {
+				participants = append(participants, strings.TrimSpace(p))
+			}
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateService($input: NewService!) { createService(input: $input) { id name description createdBy { id email } participants { id email } createdAt updatedAt } }`
+
+	input := map[string]interface{}{
+		"name":         name,
+		"description":  description,
+		"createdById":  createdBy,
+		"participants": participants,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateService *Service `json:"createService"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateService == nil {
+		return fmt.Errorf("failed to create service")
+	}
+
+	fmt.Printf("Service created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateService.ID)
+	fmt.Printf("Name: %s\n", result.CreateService.Name)
+
+	return nil
+}
+
+func serviceUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	name := cmd.String("name")
+	description := cmd.String("description")
+	participants := cmd.StringSlice("participants")
+
+	// Check if any updates are provided
+	if name == "" && description == "" && len(participants) == 0 {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	// Build the input dynamically
+	input := make(map[string]interface{})
+	if name != "" {
+		input["name"] = name
+	}
+	if description != "" {
+		input["description"] = description
+	}
+	if len(participants) > 0 {
+		input["participants"] = participants
+	}
+
+	mutation := `mutation UpdateService($id: ID!, $input: UpdateServiceInput!) { updateService(id: $id, input: $input) { id name description createdBy { id email } participants { id email } createdAt updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateService *Service `json:"updateService"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateService == nil {
+		return fmt.Errorf("service not found")
+	}
+
+	fmt.Printf("Service updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateService.ID)
+	fmt.Printf("Name: %s\n", result.UpdateService.Name)
+
+	return nil
+}
+
+func serviceDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete service %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteService($id: ID!) { deleteService(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteService bool `json:"deleteService"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteService {
+		fmt.Printf("Service %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete service %s.\n", id)
+	}
+
+	return nil
+}

+ 590 - 0
arp_cli/cmd/task.go

@@ -0,0 +1,590 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// TaskCommand returns the task command
+func TaskCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "task",
+		Usage: "Manage tasks",
+		Description: `Manage ARP tasks. Tasks are work items that can be assigned to users.
+
+Tasks have statuses, priorities, and due dates. Use this command to
+create, list, update, and delete tasks. You can also watch for real-time task updates.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all tasks",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: taskList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a task by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Task ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: taskGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new task",
+				Action: taskCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "title",
+						Aliases: []string{"t"},
+						Usage:   "Task title",
+					},
+					&cli.StringFlag{
+						Name:    "content",
+						Aliases: []string{"c"},
+						Usage:   "Task content",
+					},
+					&cli.StringFlag{
+						Name:    "created-by",
+						Aliases: []string{"b"},
+						Usage:   "Creator user ID",
+					},
+					&cli.StringFlag{
+						Name:    "assignee",
+						Aliases: []string{"a"},
+						Usage:   "Assignee user ID",
+					},
+					&cli.StringFlag{
+						Name:    "status",
+						Aliases: []string{"s"},
+						Usage:   "Status ID",
+					},
+					&cli.StringFlag{
+						Name:    "due-date",
+						Aliases: []string{"d"},
+						Usage:   "Due date",
+					},
+					&cli.StringFlag{
+						Name:    "priority",
+						Aliases: []string{"p"},
+						Usage:   "Priority (low, medium, high)",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a task",
+				Action: taskUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Task ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "title",
+						Aliases: []string{"t"},
+						Usage:   "Task title",
+					},
+					&cli.StringFlag{
+						Name:    "content",
+						Aliases: []string{"c"},
+						Usage:   "Task content",
+					},
+					&cli.StringFlag{
+						Name:    "assignee",
+						Aliases: []string{"a"},
+						Usage:   "Assignee user ID",
+					},
+					&cli.StringFlag{
+						Name:    "status",
+						Aliases: []string{"s"},
+						Usage:   "Status ID",
+					},
+					&cli.StringFlag{
+						Name:    "due-date",
+						Aliases: []string{"d"},
+						Usage:   "Due date",
+					},
+					&cli.StringFlag{
+						Name:    "priority",
+						Aliases: []string{"p"},
+						Usage:   "Priority",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a task",
+				Action: taskDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "Task ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+			{
+				Name:   "watch",
+				Usage:  "Watch for real-time task updates",
+				Action: taskWatch,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "event",
+						Aliases: []string{"e"},
+						Usage:   "Event type (created, updated, deleted, all)",
+						Value:   "all",
+					},
+				},
+			},
+		},
+	}
+}
+
+type Task struct {
+	ID          string      `json:"id"`
+	Title       string      `json:"title"`
+	Content     string      `json:"content"`
+	CreatedByID string      `json:"createdById"`
+	CreatedBy   *User       `json:"createdBy"`
+	UpdatedByID string      `json:"updatedById"`
+	UpdatedBy   *User       `json:"updatedBy"`
+	AssigneeID  *string     `json:"assigneeId"`
+	Assignee    *User       `json:"assignee"`
+	StatusID    *string     `json:"statusId"`
+	Status      *TaskStatus `json:"status"`
+	DueDate     *string     `json:"dueDate"`
+	Priority    string      `json:"priority"`
+	CreatedAt   string      `json:"createdAt"`
+	UpdatedAt   string      `json:"updatedAt"`
+}
+
+type TaskStatus struct {
+	ID        string `json:"id"`
+	Code      string `json:"code"`
+	Label     string `json:"label"`
+	CreatedAt string `json:"createdAt"`
+	UpdatedAt string `json:"updatedAt"`
+}
+
+func taskList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Tasks { tasks { id title priority createdBy { id email } assignee { id email } status { id code label } dueDate createdAt updatedAt } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Tasks []Task `json:"tasks"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Tasks)
+	}
+
+	if len(result.Tasks) == 0 {
+		fmt.Println("No tasks found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Title", "Priority", "Status", "Assignee", "Due Date"})
+	
+
+	for _, t := range result.Tasks {
+		status := ""
+		if t.Status != nil {
+			status = t.Status.Label
+		}
+		assignee := ""
+		if t.Assignee != nil {
+			assignee = t.Assignee.Email
+		}
+		dueDate := ""
+		if t.DueDate != nil {
+			dueDate = *t.DueDate
+		}
+		table.Append([]string{t.ID, t.Title, t.Priority, status, assignee, dueDate})
+	}
+
+	table.Render()
+	return nil
+}
+
+func taskGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query Task($id: ID!) { task(id: $id) { id title content createdBy { id email } assignee { id email } status { id code label } dueDate priority createdAt updatedAt } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Task *Task `json:"task"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.Task == nil {
+		return fmt.Errorf("task not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Task)
+	}
+
+	t := result.Task
+	fmt.Printf("ID: %s\n", t.ID)
+	fmt.Printf("Title: %s\n", t.Title)
+	fmt.Printf("Content: %s\n", t.Content)
+	if t.CreatedBy != nil {
+		fmt.Printf("Created By: %s\n", t.CreatedBy.Email)
+	}
+	if t.Assignee != nil {
+		fmt.Printf("Assignee: %s\n", t.Assignee.Email)
+	}
+	if t.Status != nil {
+		fmt.Printf("Status: %s (%s)\n", t.Status.Label, t.Status.Code)
+	}
+	if t.DueDate != nil {
+		fmt.Printf("Due Date: %s\n", *t.DueDate)
+	}
+	fmt.Printf("Priority: %s\n", t.Priority)
+	fmt.Printf("Created At: %s\n", t.CreatedAt)
+	fmt.Printf("Updated At: %s\n", t.UpdatedAt)
+
+	return nil
+}
+
+func taskCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	title := cmd.String("title")
+	content := cmd.String("content")
+	createdBy := cmd.String("created-by")
+	assignee := cmd.String("assignee")
+	status := cmd.String("status")
+	dueDate := cmd.String("due-date")
+	priority := cmd.String("priority")
+
+	if title == "" {
+		prompt := &survey.Input{Message: "Title:"}
+		if err := survey.AskOne(prompt, &title, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if content == "" {
+		prompt := &survey.Multiline{Message: "Content:"}
+		if err := survey.AskOne(prompt, &content, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if createdBy == "" {
+		prompt := &survey.Input{Message: "Creator user ID:"}
+		if err := survey.AskOne(prompt, &createdBy, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if priority == "" {
+		prompt := &survey.Select{
+			Message: "Priority:",
+			Options: []string{"low", "medium", "high"},
+			Default: "medium",
+		}
+		if err := survey.AskOne(prompt, &priority); err != nil {
+			return err
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateTask($input: NewTask!) { createTask(input: $input) { id title priority createdBy { id email } assignee { id email } status { id code label } dueDate createdAt updatedAt } }`
+
+	input := map[string]interface{}{
+		"title":       title,
+		"content":     content,
+		"createdById": createdBy,
+		"priority":    priority,
+	}
+	if assignee != "" {
+		input["assigneeId"] = assignee
+	}
+	if status != "" {
+		input["statusId"] = status
+	}
+	if dueDate != "" {
+		input["dueDate"] = dueDate
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateTask *Task `json:"createTask"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateTask == nil {
+		return fmt.Errorf("failed to create task")
+	}
+
+	fmt.Printf("Task created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateTask.ID)
+	fmt.Printf("Title: %s\n", result.CreateTask.Title)
+
+	return nil
+}
+
+func taskUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	title := cmd.String("title")
+	content := cmd.String("content")
+	assignee := cmd.String("assignee")
+	status := cmd.String("status")
+	dueDate := cmd.String("due-date")
+	priority := cmd.String("priority")
+
+	if title == "" && content == "" && assignee == "" && status == "" && dueDate == "" && priority == "" {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := make(map[string]interface{})
+	if title != "" {
+		input["title"] = title
+	}
+	if content != "" {
+		input["content"] = content
+	}
+	if assignee != "" {
+		input["assigneeId"] = assignee
+	}
+	if status != "" {
+		input["statusId"] = status
+	}
+	if dueDate != "" {
+		input["dueDate"] = dueDate
+	}
+	if priority != "" {
+		input["priority"] = priority
+	}
+
+	mutation := `mutation UpdateTask($id: ID!, $input: UpdateTaskInput!) { updateTask(id: $id, input: $input) { id title priority status { id code label } createdAt updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateTask *Task `json:"updateTask"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateTask == nil {
+		return fmt.Errorf("task not found")
+	}
+
+	fmt.Printf("Task updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateTask.ID)
+	fmt.Printf("Title: %s\n", result.UpdateTask.Title)
+
+	return nil
+}
+
+func taskDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete task %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteTask($id: ID!) { deleteTask(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteTask bool `json:"deleteTask"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteTask {
+		fmt.Printf("Task %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete task %s.\n", id)
+	}
+
+	return nil
+}
+
+func taskWatch(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	eventType := cmd.String("event")
+
+	wsClient := client.NewWebSocketClient(cfg.ServerURL, cfg.Token)
+
+	if err := wsClient.Connect(); err != nil {
+		return fmt.Errorf("failed to connect: %w", err)
+	}
+	defer wsClient.Close()
+
+	fmt.Printf("Watching for task events (type: %s)...\n", eventType)
+	fmt.Println("Press Ctrl+C to stop.")
+
+	var subscription string
+	switch eventType {
+	case "created":
+		subscription = "subscription { taskCreated { id title priority status { id code label } createdAt } }"
+	case "updated":
+		subscription = "subscription { taskUpdated { id title priority status { id code label } updatedAt } }"
+	case "deleted":
+		subscription = "subscription { taskDeleted { id title } }"
+	default:
+		subscription = "subscription { taskCreated { id title priority status { id code label } createdAt } taskUpdated { id title priority status { id code label } updatedAt } taskDeleted { id title } }"
+	}
+
+	if err := wsClient.Subscribe("1", subscription, nil); err != nil {
+		return fmt.Errorf("failed to subscribe: %w", err)
+	}
+
+	for {
+		select {
+		case msg := <-wsClient.Messages():
+			fmt.Printf("Event: %s\n", string(msg))
+		case err := <-wsClient.Errors():
+			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		case <-wsClient.Done():
+			return nil
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}

+ 429 - 0
arp_cli/cmd/user.go

@@ -0,0 +1,429 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"gogs.dmsc.dev/arp/arp_cli/client"
+	"gogs.dmsc.dev/arp/arp_cli/config"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/olekukonko/tablewriter"
+	"github.com/urfave/cli/v3"
+)
+
+// UserCommand returns the user command
+func UserCommand() *cli.Command {
+	return &cli.Command{
+		Name:  "user",
+		Usage: "Manage users",
+		Description: `Manage ARP users. Users are accounts that can authenticate and interact with the system.
+
+Users can have roles assigned to them which grant permissions. Use this command to
+create, list, update, and delete users.`,
+		Commands: []*cli.Command{
+			{
+				Name:    "list",
+				Aliases: []string{"ls"},
+				Usage:   "List all users",
+				Flags: []cli.Flag{
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: userList,
+			},
+			{
+				Name:  "get",
+				Usage: "Get a user by ID",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "User ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "json",
+						Aliases: []string{"j"},
+						Usage:   "Output as JSON",
+					},
+				},
+				Action: userGet,
+			},
+			{
+				Name:   "create",
+				Usage:  "Create a new user",
+				Action: userCreate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:    "email",
+						Aliases: []string{"e"},
+						Usage:   "User email",
+					},
+					&cli.StringFlag{
+						Name:    "password",
+						Aliases: []string{"p"},
+						Usage:   "User password",
+					},
+					&cli.StringSliceFlag{
+						Name:    "roles",
+						Aliases: []string{"r"},
+						Usage:   "Role IDs to assign",
+					},
+				},
+			},
+			{
+				Name:   "update",
+				Usage:  "Update a user",
+				Action: userUpdate,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "User ID",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:    "email",
+						Aliases: []string{"e"},
+						Usage:   "User email",
+					},
+					&cli.StringFlag{
+						Name:    "password",
+						Aliases: []string{"p"},
+						Usage:   "User password",
+					},
+					&cli.StringSliceFlag{
+						Name:    "roles",
+						Aliases: []string{"r"},
+						Usage:   "Role IDs to assign",
+					},
+				},
+			},
+			{
+				Name:   "delete",
+				Usage:  "Delete a user",
+				Action: userDelete,
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     "id",
+						Aliases:  []string{"i"},
+						Usage:    "User ID",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:    "yes",
+						Aliases: []string{"y"},
+						Usage:   "Skip confirmation",
+					},
+				},
+			},
+		},
+	}
+}
+
+type User struct {
+	ID        string `json:"id"`
+	Email     string `json:"email"`
+	Roles     []Role `json:"roles"`
+	CreatedAt string `json:"createdAt"`
+	UpdatedAt string `json:"updatedAt"`
+}
+
+type Role struct {
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
+func userList(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	query := "query Users { users { id email roles { id name } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, nil)
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		Users []User `json:"users"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.Users)
+	}
+
+	if len(result.Users) == 0 {
+		fmt.Println("No users found.")
+		return nil
+	}
+
+	table := tablewriter.NewWriter(os.Stdout)
+	table.Header([]string{"ID", "Email", "Roles", "Created At"})
+	
+
+	for _, u := range result.Users {
+		roles := make([]string, len(u.Roles))
+		for i, r := range u.Roles {
+			roles[i] = r.Name
+		}
+		table.Append([]string{u.ID, u.Email, strings.Join(roles, ", "), u.CreatedAt})
+	}
+
+	table.Render()
+	return nil
+}
+
+func userGet(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	id := cmd.String("id")
+	query := "query User($id: ID!) { user(id: $id) { id email roles { id name description } createdAt updatedAt } }"
+
+	resp, err := c.Query(query, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		User *User `json:"user"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.User == nil {
+		return fmt.Errorf("user not found")
+	}
+
+	if cmd.Bool("json") {
+		enc := json.NewEncoder(os.Stdout)
+		enc.SetIndent("", "  ")
+		return enc.Encode(result.User)
+	}
+
+	u := result.User
+	fmt.Printf("ID: %s\n", u.ID)
+	fmt.Printf("Email: %s\n", u.Email)
+	fmt.Printf("Roles:\n")
+	for _, r := range u.Roles {
+		fmt.Printf("  - %s (%s): %s\n", r.Name, r.ID, r.Description)
+	}
+	fmt.Printf("Created At: %s\n", u.CreatedAt)
+	fmt.Printf("Updated At: %s\n", u.UpdatedAt)
+
+	return nil
+}
+
+func userCreate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	email := cmd.String("email")
+	password := cmd.String("password")
+	roles := cmd.StringSlice("roles")
+
+	if email == "" {
+		prompt := &survey.Input{Message: "Email:"}
+		if err := survey.AskOne(prompt, &email, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if password == "" {
+		prompt := &survey.Password{Message: "Password:"}
+		if err := survey.AskOne(prompt, &password, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+	}
+
+	if len(roles) == 0 {
+		var rolesStr string
+		prompt := &survey.Input{Message: "Role IDs (comma-separated):"}
+		if err := survey.AskOne(prompt, &rolesStr, survey.WithValidator(survey.Required)); err != nil {
+			return err
+		}
+		for _, r := range strings.Split(rolesStr, ",") {
+			roles = append(roles, strings.TrimSpace(r))
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation CreateUser($input: NewUser!) { createUser(input: $input) { id email roles { id name } createdAt updatedAt } }`
+
+	input := map[string]interface{}{
+		"email":    email,
+		"password": password,
+		"roles":    roles,
+	}
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		CreateUser *User `json:"createUser"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.CreateUser == nil {
+		return fmt.Errorf("failed to create user")
+	}
+
+	fmt.Printf("User created successfully!\n")
+	fmt.Printf("ID: %s\n", result.CreateUser.ID)
+	fmt.Printf("Email: %s\n", result.CreateUser.Email)
+
+	return nil
+}
+
+func userUpdate(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	email := cmd.String("email")
+	password := cmd.String("password")
+	roles := cmd.StringSlice("roles")
+
+	if email == "" && password == "" && len(roles) == 0 {
+		fmt.Println("No updates provided. Use flags to specify what to update.")
+		return nil
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	input := make(map[string]interface{})
+	if email != "" {
+		input["email"] = email
+	}
+	if password != "" {
+		input["password"] = password
+	}
+	if len(roles) > 0 {
+		input["roles"] = roles
+	}
+
+	mutation := `mutation UpdateUser($id: ID!, $input: UpdateUserInput!) { updateUser(id: $id, input: $input) { id email roles { id name } createdAt updatedAt } }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		UpdateUser *User `json:"updateUser"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.UpdateUser == nil {
+		return fmt.Errorf("user not found")
+	}
+
+	fmt.Printf("User updated successfully!\n")
+	fmt.Printf("ID: %s\n", result.UpdateUser.ID)
+	fmt.Printf("Email: %s\n", result.UpdateUser.Email)
+
+	return nil
+}
+
+func userDelete(ctx context.Context, cmd *cli.Command) error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	if err := RequireAuth(cfg); err != nil {
+		return err
+	}
+
+	id := cmd.String("id")
+	skipConfirm := cmd.Bool("yes")
+
+	if !skipConfirm {
+		confirm := false
+		prompt := &survey.Confirm{
+			Message: fmt.Sprintf("Are you sure you want to delete user %s?", id),
+			Default: false,
+		}
+		if err := survey.AskOne(prompt, &confirm); err != nil {
+			return err
+		}
+		if !confirm {
+			fmt.Println("Deletion cancelled.")
+			return nil
+		}
+	}
+
+	c := client.New(cfg.ServerURL)
+	c.SetToken(cfg.Token)
+
+	mutation := `mutation DeleteUser($id: ID!) { deleteUser(id: $id) }`
+
+	resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
+	if err != nil {
+		return err
+	}
+
+	var result struct {
+		DeleteUser bool `json:"deleteUser"`
+	}
+	if err := json.Unmarshal(resp.Data, &result); err != nil {
+		return err
+	}
+
+	if result.DeleteUser {
+		fmt.Printf("User %s deleted successfully.\n", id)
+	} else {
+		fmt.Printf("Failed to delete user %s.\n", id)
+	}
+
+	return nil
+}

+ 99 - 0
arp_cli/config/config.go

@@ -0,0 +1,99 @@
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+)
+
+// Config holds the CLI configuration
+type Config struct {
+	ServerURL string `json:"server_url"`
+	Token     string `json:"token"`
+	UserEmail string `json:"user_email"`
+}
+
+// configPath returns the path to the config file
+func configPath() (string, error) {
+	homeDir, err := os.UserHomeDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(homeDir, ".arp_cli", "config.json"), nil
+}
+
+// Load reads the configuration from disk
+func Load() (*Config, error) {
+	path, err := configPath()
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return &Config{}, nil
+		}
+		return nil, err
+	}
+
+	var cfg Config
+	if err := json.Unmarshal(data, &cfg); err != nil {
+		return nil, err
+	}
+
+	return &cfg, nil
+}
+
+// Save writes the configuration to disk
+func Save(cfg *Config) error {
+	path, err := configPath()
+	if err != nil {
+		return err
+	}
+
+	// Ensure directory exists
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0700); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0600)
+}
+
+// Clear removes the stored token but keeps the server URL
+func Clear() error {
+	cfg, err := Load()
+	if err != nil {
+		return err
+	}
+	cfg.Token = ""
+	cfg.UserEmail = ""
+	return Save(cfg)
+}
+
+// SetServerURL updates the server URL in the config
+func SetServerURL(url string) error {
+	cfg, err := Load()
+	if err != nil {
+		return err
+	}
+	cfg.ServerURL = url
+	return Save(cfg)
+}
+
+// SetToken updates the token and user email in the config
+func SetToken(token, email string) error {
+	cfg, err := Load()
+	if err != nil {
+		return err
+	}
+	cfg.Token = token
+	cfg.UserEmail = email
+	return Save(cfg)
+}

+ 28 - 0
arp_cli/go.mod

@@ -0,0 +1,28 @@
+module gogs.dmsc.dev/arp/arp_cli
+
+go 1.25.3
+
+require (
+	github.com/AlecAivazis/survey/v2 v2.3.7
+	github.com/gorilla/websocket v1.5.0
+	github.com/olekukonko/tablewriter v1.1.3
+	github.com/urfave/cli/v3 v3.7.0
+)
+
+require (
+	github.com/clipperhouse/displaywidth v0.6.2 // indirect
+	github.com/clipperhouse/stringish v0.1.1 // indirect
+	github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
+	github.com/fatih/color v1.18.0 // indirect
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+	github.com/mattn/go-colorable v0.1.14 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.19 // indirect
+	github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
+	github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
+	github.com/olekukonko/errors v1.1.0 // indirect
+	github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
+	golang.org/x/sys v0.29.0 // indirect
+	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+	golang.org/x/text v0.4.0 // indirect
+)

+ 83 - 0
arp_cli/go.sum

@@ -0,0 +1,83 @@
+github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
+github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
+github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
+github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
+github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
+github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
+github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
+github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
+github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
+github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
+github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
+github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
+github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
+github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
+github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
+github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 78 - 0
arp_cli/main.go

@@ -0,0 +1,78 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+
+	"gogs.dmsc.dev/arp/arp_cli/cmd"
+
+	"github.com/urfave/cli/v3"
+)
+
+func main() {
+	// Create context with cancellation for graceful shutdown
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	// Handle interrupt signals
+	sigChan := make(chan os.Signal, 1)
+	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-sigChan
+		cancel()
+	}()
+
+	// Build and run the CLI
+	app := &cli.Command{
+		Name:  "arp_cli",
+		Usage: "Command-line interface for ARP (Agent-native ERP)",
+		Description: `ARP CLI is a command-line tool for interacting with ARP GraphQL servers.
+
+It provides CRUD operations for Services, Users, Notes, Tasks, Channels, Messages,
+Roles, and Permissions. Real-time updates are available via WebSocket subscriptions
+for Tasks and Messages.
+
+First, login to an ARP server using 'arp_cli login' to store your credentials.
+Then use the various commands to manage your ARP data.`,
+		Version: "1.0.0",
+		Flags: []cli.Flag{
+			&cli.StringFlag{
+				Name:    "url",
+				Aliases: []string{"u"},
+				Usage:   "ARP server URL",
+				Sources: cli.EnvVars("ARP_URL"),
+			},
+			&cli.StringFlag{
+				Name:    "output",
+				Aliases: []string{"o"},
+				Usage:   "Output format (table, json)",
+				Value:   "table",
+			},
+		},
+		Commands: []*cli.Command{
+			cmd.RootCommand(),
+			cmd.LoginCommand(),
+			cmd.ConfigCommand(),
+			cmd.ServiceCommand(),
+			cmd.UserCommand(),
+			cmd.NoteCommand(),
+			cmd.TaskCommand(),
+			cmd.ChannelCommand(),
+			cmd.MessageCommand(),
+			cmd.RoleCommand(),
+			cmd.PermissionCommand(),
+		},
+		Before: func(ctx context.Context, command *cli.Command) (context.Context, error) {
+			// Store global flags in context for later use
+			return ctx, nil
+		},
+	}
+
+	if err := app.Run(ctx, os.Args); err != nil {
+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		os.Exit(1)
+	}
+}

+ 91 - 0
init_prod.sql

@@ -0,0 +1,91 @@
+-- ARP Initial Data Bootstrap Script
+-- Run this script to set up initial permissions, roles, and an admin user
+-- 
+-- Usage:
+--   sqlite3 arp.db < init.sql
+--
+-- Note: The password hash below is for "secret123" using bcrypt.
+-- You can generate a new hash with: 
+-- go run -e 'package main; import ("fmt"; "golang.org/x/crypto/bcrypt"); func main() { h, _ := bcrypt.GenerateFromPassword([]byte("your-password"), 10); fmt.Println(string(h)) }'
+-- or
+-- python3 -c "import bcrypt; print(bcrypt.hashpw(b'your_password', bcrypt.gensalt()).decode())"
+
+-- Permissions
+INSERT INTO permissions (id, code, description, created_at, updated_at) VALUES 
+  (1, 'user:create', 'Create users', datetime('now'), datetime('now')),
+  (2, 'user:read', 'Read users', datetime('now'), datetime('now')),
+  (3, 'user:update', 'Update users', datetime('now'), datetime('now')),
+  (4, 'user:delete', 'Delete users', datetime('now'), datetime('now')),
+  (5, 'role:create', 'Create roles', datetime('now'), datetime('now')),
+  (6, 'role:read', 'Read roles', datetime('now'), datetime('now')),
+  (7, 'role:update', 'Update roles', datetime('now'), datetime('now')),
+  (8, 'role:delete', 'Delete roles', datetime('now'), datetime('now')),
+  (9, 'permission:create', 'Create permissions', datetime('now'), datetime('now')),
+  (10, 'permission:read', 'Read permissions', datetime('now'), datetime('now')),
+  (11, 'permission:update', 'Update permissions', datetime('now'), datetime('now')),
+  (12, 'permission:delete', 'Delete permissions', datetime('now'), datetime('now')),
+  (13, 'service:create', 'Create services', datetime('now'), datetime('now')),
+  (14, 'service:read', 'Read services', datetime('now'), datetime('now')),
+  (15, 'service:update', 'Update services', datetime('now'), datetime('now')),
+  (16, 'service:delete', 'Delete services', datetime('now'), datetime('now')),
+  (17, 'task:create', 'Create tasks', datetime('now'), datetime('now')),
+  (18, 'task:read', 'Read tasks', datetime('now'), datetime('now')),
+  (19, 'task:update', 'Update tasks', datetime('now'), datetime('now')),
+  (20, 'task:delete', 'Delete tasks', datetime('now'), datetime('now')),
+  (21, 'note:create', 'Create notes', datetime('now'), datetime('now')),
+  (22, 'note:read', 'Read notes', datetime('now'), datetime('now')),
+  (23, 'note:update', 'Update notes', datetime('now'), datetime('now')),
+  (24, 'note:delete', 'Delete notes', datetime('now'), datetime('now')),
+  (25, 'channel:create', 'Create channels', datetime('now'), datetime('now')),
+  (26, 'channel:read', 'Read channels', datetime('now'), datetime('now')),
+  (27, 'channel:update', 'Update channels', datetime('now'), datetime('now')),
+  (28, 'channel:delete', 'Delete channels', datetime('now'), datetime('now')),
+  (29, 'message:create', 'Create messages', datetime('now'), datetime('now')),
+  (30, 'message:read', 'Read messages', datetime('now'), datetime('now')),
+  (31, 'message:update', 'Update messages', datetime('now'), datetime('now')),
+  (32, 'message:delete', 'Delete messages', datetime('now'), datetime('now')),
+  (33, 'taskstatus:create', 'Create task statuses', datetime('now'), datetime('now')),
+  (34, 'taskstatus:read', 'Read task statuses', datetime('now'), datetime('now')),
+  (35, 'taskstatus:update', 'Update task statuses', datetime('now'), datetime('now')),
+  (36, 'taskstatus:delete', 'Delete task statuses', datetime('now'), datetime('now'));
+
+-- Roles
+INSERT INTO roles (id, name, description, created_at, updated_at) VALUES 
+  (1, 'admin', 'Administrator with full access', datetime('now'), datetime('now')),
+  (2, 'manager', 'Service manager with task management', datetime('now'), datetime('now')),
+  (3, 'user', 'Regular user with limited access', datetime('now'), datetime('now'));
+
+-- Role-Permission associations (admin gets all permissions)
+INSERT INTO role_permissions (role_id, permission_id) 
+SELECT 1, id FROM permissions;
+
+-- Manager role permissions (service, task, note operations)
+INSERT INTO role_permissions (role_id, permission_id) VALUES
+  (2, 13), (2, 14), (2, 15), (2, 16), -- service:*
+  (2, 17), (2, 18), (2, 19), (2, 20), -- task:*
+  (2, 21), (2, 22), (2, 23), (2, 24), -- note:*
+  (2, 25), (2, 26), (2, 27), (2, 28), -- channel:*
+  (2, 29), (2, 30), (2, 31), (2, 32), -- message:*
+  (2, 33), (2, 34), (2, 35), (2, 36); -- taskstatus:*
+
+-- User role permissions (read-only + create notes/messages)
+INSERT INTO role_permissions (role_id, permission_id) VALUES
+  (3, 2), (3, 6), (3, 10), (3, 14), (3, 18), (3, 22), (3, 26), (3, 30), (3, 34), -- read permissions
+  (3, 21), (3, 29); -- create notes and messages
+
+-- Admin user (password: secret123)
+-- bcrypt hash generated with cost 10
+INSERT INTO users (id, email, password, created_at, updated_at) VALUES 
+  (1, 'admin@example.com', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', datetime('now'), datetime('now'));
+
+-- Associate admin user with admin role
+INSERT INTO user_roles (user_id, role_id) VALUES (1, 1);
+
+-- Task Statuses (common workflow states)
+INSERT INTO task_statuses (id, code, label, created_at, updated_at) VALUES 
+  (1, 'open', 'Open', datetime('now'), datetime('now')),
+  (2, 'in_progress', 'In Progress', datetime('now'), datetime('now')),
+  (3, 'blocked', 'Blocked', datetime('now'), datetime('now')),
+  (4, 'review', 'In Review', datetime('now'), datetime('now')),
+  (5, 'done', 'Done', datetime('now'), datetime('now')),
+  (6, 'cancelled', 'Cancelled', datetime('now'), datetime('now'));