Add CLI client subcommands and MCP server

Adds push, list, get, delete, and login subcommands backed by an HTTP
API client, plus an MCP server for tool-based access to the document
queue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 00:08:55 -07:00
parent ed3a547e54
commit 3d5f52729f
14 changed files with 1161 additions and 0 deletions

61
cmd/mcq/client.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcq/internal/client"
)
var clientFlags struct {
server string
token string
}
func addClientFlags(cmd *cobra.Command) {
cmd.Flags().StringVar(&clientFlags.server, "server", "", "MCQ server URL (env: MCQ_SERVER)")
cmd.Flags().StringVar(&clientFlags.token, "token", "", "auth token (env: MCQ_TOKEN)")
}
func newClient() (*client.Client, error) {
server := clientFlags.server
if server == "" {
server = os.Getenv("MCQ_SERVER")
}
if server == "" {
return nil, fmt.Errorf("server URL required: use --server or MCQ_SERVER")
}
token := clientFlags.token
if token == "" {
token = os.Getenv("MCQ_TOKEN")
}
if token == "" {
token = readCachedToken()
}
if token == "" {
return nil, fmt.Errorf("auth token required: use --token, MCQ_TOKEN, or run 'mcq login'")
}
return client.New(server, token), nil
}
func tokenPath() string {
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "mcq", "token")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "mcq", "token")
}
func readCachedToken() string {
data, err := os.ReadFile(tokenPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}

32
cmd/mcq/delete.go Normal file
View File

@@ -0,0 +1,32 @@
package main
import (
"context"
"fmt"
"github.com/spf13/cobra"
)
func deleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <slug>",
Short: "Delete a document from the queue",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.DeleteDocument(context.Background(), args[0]); err != nil {
return fmt.Errorf("delete document: %w", err)
}
fmt.Printf("deleted %s\n", args[0])
return nil
},
}
addClientFlags(cmd)
return cmd
}

34
cmd/mcq/get.go Normal file
View File

@@ -0,0 +1,34 @@
package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
)
func getCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "get <slug>",
Short: "Get a document's markdown body",
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
doc, err := c.GetDocument(context.Background(), args[0])
if err != nil {
return fmt.Errorf("get document: %w", err)
}
_, _ = fmt.Fprint(os.Stdout, doc.Body)
return nil
},
}
addClientFlags(cmd)
return cmd
}

44
cmd/mcq/list.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"context"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
)
func listCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List documents in the queue",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
c, err := newClient()
if err != nil {
return err
}
docs, err := c.ListDocuments(context.Background())
if err != nil {
return fmt.Errorf("list documents: %w", err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "SLUG\tTITLE\tPUSHED BY\tPUSHED AT\tREAD")
for _, d := range docs {
read := "no"
if d.Read {
read = "yes"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Slug, d.Title, d.PushedBy, d.PushedAt, read)
}
_ = w.Flush()
return nil
},
}
addClientFlags(cmd)
return cmd
}

71
cmd/mcq/login.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
"git.wntrmute.dev/mc/mcq/internal/client"
)
func loginCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate with MCIAS and cache a token",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
server := clientFlags.server
if server == "" {
server = os.Getenv("MCQ_SERVER")
}
if server == "" {
return fmt.Errorf("server URL required: use --server or MCQ_SERVER")
}
reader := bufio.NewReader(os.Stdin)
fmt.Print("Username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
fmt.Print("Password: ")
passBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("read password: %w", err)
}
fmt.Println()
password := string(passBytes)
fmt.Print("TOTP code (blank if none): ")
totpCode, _ := reader.ReadString('\n')
totpCode = strings.TrimSpace(totpCode)
c := client.New(server, "")
token, err := c.Login(context.Background(), username, password, totpCode)
if err != nil {
return fmt.Errorf("login: %w", err)
}
path := tokenPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
return fmt.Errorf("cache token: %w", err)
}
fmt.Printf("token cached to %s\n", path)
return nil
},
}
cmd.Flags().StringVar(&clientFlags.server, "server", "", "MCQ server URL (env: MCQ_SERVER)")
return cmd
}

View File

@@ -16,6 +16,12 @@ func main() {
}
root.AddCommand(serverCmd())
root.AddCommand(pushCmd())
root.AddCommand(listCmd())
root.AddCommand(getCmd())
root.AddCommand(deleteCmd())
root.AddCommand(loginCmd())
root.AddCommand(mcpCmd())
if err := root.Execute(); err != nil {
os.Exit(1)

40
cmd/mcq/mcp.go Normal file
View File

@@ -0,0 +1,40 @@
package main
import (
"context"
"fmt"
"os"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcq/internal/client"
"git.wntrmute.dev/mc/mcq/internal/mcpserver"
)
func mcpCmd() *cobra.Command {
return &cobra.Command{
Use: "mcp",
Short: "Start MCP stdio server for Claude integration",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return runMCP()
},
}
}
func runMCP() error {
serverURL := os.Getenv("MCQ_SERVER")
if serverURL == "" {
return fmt.Errorf("MCQ_SERVER environment variable required")
}
token := os.Getenv("MCQ_TOKEN")
if token == "" {
return fmt.Errorf("MCQ_TOKEN environment variable required")
}
c := client.New(serverURL, token)
mcpSrv := mcpserver.New(c, version)
stdioSrv := server.NewStdioServer(mcpSrv)
return stdioSrv.Listen(context.Background(), os.Stdin, os.Stdout)
}

58
cmd/mcq/push.go Normal file
View File

@@ -0,0 +1,58 @@
package main
import (
"context"
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"git.wntrmute.dev/mc/mcq/internal/client"
)
func pushCmd() *cobra.Command {
var title string
cmd := &cobra.Command{
Use: "push <slug> <file|->",
Short: "Push a document to the queue",
Args: cobra.ExactArgs(2),
RunE: func(_ *cobra.Command, args []string) error {
slug := args[0]
source := args[1]
var body []byte
var err error
if source == "-" {
body, err = io.ReadAll(os.Stdin)
} else {
body, err = os.ReadFile(source)
}
if err != nil {
return fmt.Errorf("read input: %w", err)
}
if title == "" {
title = client.ExtractTitle(string(body), slug)
}
c, err := newClient()
if err != nil {
return err
}
doc, err := c.PutDocument(context.Background(), slug, title, string(body))
if err != nil {
return fmt.Errorf("push document: %w", err)
}
fmt.Printf("pushed %s (%s)\n", doc.Slug, doc.Title)
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "document title (default: first H1 heading, or slug)")
addClientFlags(cmd)
return cmd
}