~kris/9p

llm9p

054039ea9bfbafe6e6a89c7800e8b7ef9ea3df2b — pdfinn 7 months ago 2038697
feat: Add CLI backend for Claude Max subscription

Add support for using Claude Code CLI as an alternative backend,
allowing users with Claude Max subscriptions to use llm9p without
API tokens.

New files:
- internal/llm/backend.go: Backend interface for swappable LLM providers
- internal/llm/cli_client.go: CLI-based client using `claude` command

Changes:
- Add -backend flag: 'api' (default) or 'cli'
- Refactor llmfs to use Backend interface instead of concrete Client
- Model names normalized for CLI (opus, sonnet, haiku)

Usage:
  ./llm9p -backend cli  # Uses Claude Max subscription
  ./llm9p -backend api  # Uses Anthropic API (default)

Limitations of CLI backend:
- Token counting not available (always 0)
- Streaming is simulated (full response as single chunk)
- Uses short model names (opus, sonnet, haiku)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
M CLAUDE.md => CLAUDE.md +18 -4
@@ 10,9 10,12 @@ This guide is for Claude Code and developers working on the llm9p codebase.
# Build
go build -o llm9p ./cmd/llm9p

# Run
# Run with Anthropic API
ANTHROPIC_API_KEY=sk-... ./llm9p -addr :5640

# Run with Claude Max subscription (via CLI)
./llm9p -addr :5640 -backend cli

# Run with debug logging
ANTHROPIC_API_KEY=sk-... ./llm9p -addr :5640 -debug
```


@@ 86,11 89,13 @@ llm9p/
   - No external dependencies (stdlib only)
   - `File` and `Dir` interfaces define the filesystem abstraction

2. **LLM Client (`internal/llm/client.go`)**
   - Wraps Anthropic SDK
2. **LLM Client (`internal/llm/`)**
   - `backend.go` - Backend interface for swappable LLM providers
   - `client.go` - Anthropic API client (requires API key)
   - `cli_client.go` - Claude Code CLI client (uses Max subscription)
   - Manages conversation state
   - Supports both sync and streaming responses
   - Tracks token usage
   - Tracks token usage (API only)

3. **LLM Filesystem (`internal/llmfs/`)**
   - Implements each file in the LLM filesystem


@@ 322,6 327,15 @@ The following scenarios have been tested and verified working:
- [x] Short response ("Write a haiku") streams correctly
- [x] Long response ("Count 1 to 20") streams all content

### CLI Backend (Claude Max subscription)
- [x] Server starts with `-backend cli` flag
- [x] Model returns "sonnet" (normalized name)
- [x] `echo "What is 2+2?" | 9p write ask` returns correct answer
- [x] Multi-turn conversation maintains context
- [x] Model switching works (haiku, sonnet, opus)
- [x] Conversation reset works via `new` file
- [x] System messages work via `context` file

## Future Enhancements

- [ ] Multiple conversation support (via subdirectories)

M README.md => README.md +28 -1
@@ 32,11 32,27 @@ go build -o llm9p ./cmd/llm9p

### Start the Server

**Option A: Using Anthropic API** (requires API key)

```bash
export ANTHROPIC_API_KEY=sk-ant-...
./llm9p -addr :5640
```

**Option B: Using Claude Max Subscription** (via Claude Code CLI)

If you have a Claude Max subscription and the Claude Code CLI installed:

```bash
./llm9p -addr :5640 -backend cli
```

This uses your Claude Max subscription instead of API tokens. No API key required.

**Requirements for CLI backend:**
- Claude Code CLI installed and authenticated (`claude` command available)
- Active Claude Max subscription

### Mount the Filesystem

There are several ways to mount the filesystem depending on your environment.


@@ 219,6 235,7 @@ cat /mnt/llm/ask
| Flag | Default | Description |
|------|---------|-------------|
| `-addr` | `:5640` | Address to listen on |
| `-backend` | `api` | Backend: `api` (Anthropic API) or `cli` (Claude Code CLI) |
| `-debug` | `false` | Enable debug logging |

### Environment Variables


@@ 229,10 246,20 @@ cat /mnt/llm/ask

## Default Settings

- **Model**: `claude-sonnet-4-20250514`
- **Model**: `claude-sonnet-4-20250514` (API) or `sonnet` (CLI)
- **Temperature**: `0.7`
- **Max Tokens**: `4096`

### Backend Differences

| Feature | API Backend | CLI Backend |
|---------|-------------|-------------|
| Authentication | API key required | Claude Max subscription |
| Token counting | Accurate | Not available (always 0) |
| Model names | Full names | Aliases (opus, sonnet, haiku) |
| Streaming | True streaming | Simulated (full response) |
| Rate limits | API limits apply | Subscription limits apply |

## Requirements

- Go 1.21+

M cmd/llm9p/main.go => cmd/llm9p/main.go +32 -7
@@ 4,6 4,10 @@
//
//	ANTHROPIC_API_KEY=sk-... llm9p -addr :5640
//
// Or with Claude Max subscription (via Claude Code CLI):
//
//	llm9p -addr :5640 -backend cli
//
// Mount with:
//
//	9pfuse localhost:5640 /mnt/llm


@@ 21,6 25,7 @@ import (
	"log"
	"net"
	"os"
	"os/exec"
	"os/signal"
	"syscall"



@@ 32,18 37,38 @@ import (
func main() {
	addr := flag.String("addr", ":5640", "Address to listen on")
	debug := flag.Bool("debug", false, "Enable debug logging")
	backend := flag.String("backend", "api", "Backend to use: 'api' (Anthropic API) or 'cli' (Claude Code CLI for Max subscription)")
	flag.Parse()

	// Get API key from environment
	apiKey := os.Getenv("ANTHROPIC_API_KEY")
	if apiKey == "" {
		fmt.Fprintln(os.Stderr, "Error: ANTHROPIC_API_KEY environment variable not set")
	var client llm.Backend

	switch *backend {
	case "cli":
		// Check that claude CLI is available
		if _, err := exec.LookPath("claude"); err != nil {
			fmt.Fprintln(os.Stderr, "Error: 'claude' CLI not found in PATH")
			fmt.Fprintln(os.Stderr, "Install Claude Code CLI or use -backend api with ANTHROPIC_API_KEY")
			os.Exit(1)
		}
		client = llm.NewCLIClient()
		log.Println("Using Claude Code CLI backend (Claude Max subscription)")

	case "api":
		// Get API key from environment
		apiKey := os.Getenv("ANTHROPIC_API_KEY")
		if apiKey == "" {
			fmt.Fprintln(os.Stderr, "Error: ANTHROPIC_API_KEY environment variable not set")
			fmt.Fprintln(os.Stderr, "Set ANTHROPIC_API_KEY or use -backend cli for Claude Max subscription")
			os.Exit(1)
		}
		client = llm.NewClient(apiKey)
		log.Println("Using Anthropic API backend")

	default:
		fmt.Fprintf(os.Stderr, "Error: unknown backend '%s' (use 'api' or 'cli')\n", *backend)
		os.Exit(1)
	}

	// Create LLM client
	client := llm.NewClient(apiKey)

	// Create filesystem
	root := llmfs.NewRoot(client)


A internal/llm/backend.go => internal/llm/backend.go +41 -0
@@ 0,0 1,41 @@
// Package llm provides LLM backends for the 9P filesystem.
package llm

import "context"

// Backend defines the interface for LLM backends.
// Both API and CLI clients implement this interface.
type Backend interface {
	// Model returns the current model name
	Model() string
	// SetModel sets the model for subsequent requests
	SetModel(model string)
	// Temperature returns the current temperature
	Temperature() float64
	// SetTemperature sets the temperature (0.0-2.0)
	SetTemperature(temp float64) error
	// LastTokens returns token count from last response
	LastTokens() int
	// Messages returns conversation history
	Messages() []Message
	// MessagesJSON returns conversation history as JSON
	MessagesJSON() ([]byte, error)
	// AddSystemMessage adds a system message
	AddSystemMessage(content string)
	// Reset clears conversation history
	Reset()
	// Ask sends a prompt and returns the response (blocking)
	Ask(ctx context.Context, prompt string) (string, error)
	// StartStream begins streaming a response
	StartStream(ctx context.Context, prompt string) error
	// ReadStreamChunk reads the next streaming chunk
	ReadStreamChunk() (string, bool)
	// IsStreaming returns whether a stream is in progress
	IsStreaming() bool
	// WaitStream waits for stream to complete
	WaitStream()
}

// Verify that both clients implement Backend
var _ Backend = (*Client)(nil)
var _ Backend = (*CLIClient)(nil)

A internal/llm/cli_client.go => internal/llm/cli_client.go +368 -0
@@ 0,0 1,368 @@
// CLI backend for Claude Max subscription via Claude Code CLI.
package llm

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"os/exec"
	"strings"
	"sync"
)

// CLIClient uses the Claude Code CLI for LLM requests.
// This allows using a Claude Max subscription instead of API tokens.
type CLIClient struct {
	mu          sync.RWMutex
	model       string
	temperature float64
	messages    []Message
	lastTokens  int
	streaming   bool
	streamChan  chan string
	streamDone  chan struct{}
}

// cliResponse represents the JSON response from claude CLI
type cliResponse struct {
	Type   string `json:"type"`
	Result string `json:"result"`
}

// NewCLIClient creates a new CLI-based LLM client
func NewCLIClient() *CLIClient {
	return &CLIClient{
		model:       "sonnet", // CLI uses short model names
		temperature: 0.7,
		messages:    make([]Message, 0),
	}
}

// normalizeModel converts full model names to CLI aliases
func normalizeModel(model string) string {
	model = strings.ToLower(model)
	switch {
	case strings.Contains(model, "opus"):
		return "opus"
	case strings.Contains(model, "haiku"):
		return "haiku"
	default:
		return "sonnet"
	}
}

// Model returns the current model name
func (c *CLIClient) Model() string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.model
}

// SetModel sets the model for subsequent requests
func (c *CLIClient) SetModel(model string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.model = normalizeModel(model)
}

// Temperature returns the current temperature
func (c *CLIClient) Temperature() float64 {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.temperature
}

// SetTemperature sets the temperature for subsequent requests
func (c *CLIClient) SetTemperature(temp float64) error {
	if temp < 0.0 || temp > 2.0 {
		return fmt.Errorf("temperature must be between 0.0 and 2.0")
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	c.temperature = temp
	return nil
}

// LastTokens returns the token count from the last response
// Note: CLI doesn't provide token counts, so this is always 0
func (c *CLIClient) LastTokens() int {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.lastTokens
}

// Messages returns a copy of the conversation history
func (c *CLIClient) Messages() []Message {
	c.mu.RLock()
	defer c.mu.RUnlock()
	result := make([]Message, len(c.messages))
	copy(result, c.messages)
	return result
}

// MessagesJSON returns the conversation history as JSON
func (c *CLIClient) MessagesJSON() ([]byte, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return json.MarshalIndent(c.messages, "", "  ")
}

// AddSystemMessage adds a system message to the context
func (c *CLIClient) AddSystemMessage(content string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.messages = append([]Message{{Role: "system", Content: content}}, c.messages...)
}

// Reset clears the conversation history
func (c *CLIClient) Reset() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.messages = make([]Message, 0)
	c.lastTokens = 0
}

// buildPrompt builds a full prompt string from conversation history
func (c *CLIClient) buildPrompt() string {
	var parts []string
	for _, msg := range c.messages {
		switch msg.Role {
		case "user":
			parts = append(parts, fmt.Sprintf("Human: %s", msg.Content))
		case "assistant":
			parts = append(parts, fmt.Sprintf("Assistant: %s", msg.Content))
		}
	}
	return strings.Join(parts, "\n\n")
}

// getSystemPrompt extracts system messages as a single string
func (c *CLIClient) getSystemPrompt() string {
	var systems []string
	for _, msg := range c.messages {
		if msg.Role == "system" {
			systems = append(systems, msg.Content)
		}
	}
	return strings.Join(systems, "\n\n")
}

// Ask sends a prompt to the LLM via CLI and returns the response
func (c *CLIClient) Ask(ctx context.Context, prompt string) (string, error) {
	c.mu.Lock()
	c.messages = append(c.messages, Message{Role: "user", Content: prompt})
	fullPrompt := c.buildPrompt()
	systemPrompt := c.getSystemPrompt()
	model := c.model
	c.mu.Unlock()

	// Build claude CLI command
	args := []string{
		"--print",
		"--output-format", "json",
		"--model", model,
		"--dangerously-skip-permissions",
		"--allowedTools", "",
	}

	if systemPrompt != "" {
		args = append(args, "--system-prompt", systemPrompt)
	}

	args = append(args, "-") // Read from stdin

	cmd := exec.CommandContext(ctx, "claude", args...)
	cmd.Stdin = bytes.NewBufferString(fullPrompt)

	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	if err := cmd.Run(); err != nil {
		// Remove user message on error
		c.mu.Lock()
		if len(c.messages) > 0 {
			c.messages = c.messages[:len(c.messages)-1]
		}
		c.mu.Unlock()
		return "", fmt.Errorf("claude CLI error: %w (stderr: %s)", err, stderr.String())
	}

	// Parse JSON response
	responseText, err := parseJSONResponse(stdout.String())
	if err != nil {
		// Remove user message on error
		c.mu.Lock()
		if len(c.messages) > 0 {
			c.messages = c.messages[:len(c.messages)-1]
		}
		c.mu.Unlock()
		return "", fmt.Errorf("failed to parse CLI response: %w", err)
	}

	// Update state
	c.mu.Lock()
	c.messages = append(c.messages, Message{Role: "assistant", Content: responseText})
	c.lastTokens = 0 // CLI doesn't provide token counts
	c.mu.Unlock()

	return responseText, nil
}

// parseJSONResponse extracts the result from claude CLI JSON output
func parseJSONResponse(output string) (string, error) {
	// Try parsing each line as JSON (CLI may output multiple JSON objects)
	scanner := bufio.NewScanner(strings.NewReader(output))
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}

		var resp cliResponse
		if err := json.Unmarshal([]byte(line), &resp); err != nil {
			continue // Not valid JSON, try next line
		}

		if resp.Type == "result" && resp.Result != "" {
			return resp.Result, nil
		}
	}

	// Fallback: return raw output if no JSON result found
	output = strings.TrimSpace(output)
	if output != "" {
		return output, nil
	}

	return "", fmt.Errorf("no result in CLI output")
}

// StartStream begins streaming a response for the given prompt
// Note: CLI streaming is simulated - we run the command and feed output progressively
func (c *CLIClient) StartStream(ctx context.Context, prompt string) error {
	c.mu.Lock()
	if c.streaming {
		c.mu.Unlock()
		return fmt.Errorf("stream already in progress")
	}

	c.messages = append(c.messages, Message{Role: "user", Content: prompt})
	fullPrompt := c.buildPrompt()
	systemPrompt := c.getSystemPrompt()
	model := c.model

	c.streaming = true
	c.streamChan = make(chan string, 100)
	c.streamDone = make(chan struct{})
	c.mu.Unlock()

	go func() {
		defer func() {
			c.mu.Lock()
			c.streaming = false
			close(c.streamChan)
			close(c.streamDone)
			c.mu.Unlock()
		}()

		// Build command
		args := []string{
			"--print",
			"--output-format", "json",
			"--model", model,
			"--dangerously-skip-permissions",
			"--allowedTools", "",
		}

		if systemPrompt != "" {
			args = append(args, "--system-prompt", systemPrompt)
		}

		args = append(args, "-")

		cmd := exec.CommandContext(ctx, "claude", args...)
		cmd.Stdin = bytes.NewBufferString(fullPrompt)

		var stdout, stderr bytes.Buffer
		cmd.Stdout = &stdout
		cmd.Stderr = &stderr

		if err := cmd.Run(); err != nil {
			select {
			case c.streamChan <- fmt.Sprintf("[Error: %v]", err):
			case <-ctx.Done():
			}
			c.mu.Lock()
			if len(c.messages) > 0 {
				c.messages = c.messages[:len(c.messages)-1]
			}
			c.mu.Unlock()
			return
		}

		// Parse and send response
		responseText, err := parseJSONResponse(stdout.String())
		if err != nil {
			select {
			case c.streamChan <- fmt.Sprintf("[Error: %v]", err):
			case <-ctx.Done():
			}
			c.mu.Lock()
			if len(c.messages) > 0 {
				c.messages = c.messages[:len(c.messages)-1]
			}
			c.mu.Unlock()
			return
		}

		// Send response as a single chunk (CLI doesn't truly stream)
		select {
		case c.streamChan <- responseText:
		case <-ctx.Done():
			return
		}

		// Update state
		c.mu.Lock()
		c.messages = append(c.messages, Message{Role: "assistant", Content: responseText})
		c.lastTokens = 0
		c.mu.Unlock()
	}()

	return nil
}

// ReadStreamChunk reads the next chunk from the stream
func (c *CLIClient) ReadStreamChunk() (string, bool) {
	c.mu.RLock()
	streamChan := c.streamChan
	c.mu.RUnlock()

	if streamChan == nil {
		return "", false
	}

	chunk, ok := <-streamChan
	return chunk, ok
}

// IsStreaming returns whether a stream is currently in progress
func (c *CLIClient) IsStreaming() bool {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.streaming
}

// WaitStream waits for the current stream to complete
func (c *CLIClient) WaitStream() {
	c.mu.RLock()
	done := c.streamDone
	c.mu.RUnlock()

	if done != nil {
		<-done
	}
}

M internal/llmfs/ask.go => internal/llmfs/ask.go +2 -2
@@ 13,13 13,13 @@ import (
// AskFile is the main interaction file - write a prompt, read the response
type AskFile struct {
	*protocol.BaseFile
	client       *llm.Client
	client       llm.Backend
	mu           sync.RWMutex
	lastResponse string
}

// NewAskFile creates the ask file
func NewAskFile(client *llm.Client) *AskFile {
func NewAskFile(client llm.Backend) *AskFile {
	return &AskFile{
		BaseFile: protocol.NewBaseFile("ask", 0666),
		client:   client,

M internal/llmfs/context.go => internal/llmfs/context.go +2 -2
@@ 13,11 13,11 @@ import (
// Write: appends a system message to context
type ContextFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewContextFile creates the context file
func NewContextFile(client *llm.Client) *ContextFile {
func NewContextFile(client llm.Backend) *ContextFile {
	return &ContextFile{
		BaseFile: protocol.NewBaseFile("context", 0666),
		client:   client,

M internal/llmfs/new.go => internal/llmfs/new.go +2 -2
@@ 8,11 8,11 @@ import (
// NewFile is a write-only file that resets the conversation when written to
type NewFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewNewFile creates the new file
func NewNewFile(client *llm.Client) *NewFile {
func NewNewFile(client llm.Backend) *NewFile {
	return &NewFile{
		BaseFile: protocol.NewBaseFile("new", 0222),
		client:   client,

M internal/llmfs/root.go => internal/llmfs/root.go +1 -1
@@ 7,7 7,7 @@ import (
)

// NewRoot creates the root directory of the LLM filesystem
func NewRoot(client *llm.Client) protocol.Dir {
func NewRoot(client llm.Backend) protocol.Dir {
	root := protocol.NewStaticDir("llm")

	// Add all files

M internal/llmfs/state.go => internal/llmfs/state.go +4 -4
@@ 13,11 13,11 @@ import (
// ModelFile exposes the current model name (read/write)
type ModelFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewModelFile creates the model file
func NewModelFile(client *llm.Client) *ModelFile {
func NewModelFile(client llm.Backend) *ModelFile {
	return &ModelFile{
		BaseFile: protocol.NewBaseFile("model", 0666),
		client:   client,


@@ 51,11 51,11 @@ func (f *ModelFile) Stat() protocol.Stat {
// TemperatureFile exposes the current temperature (read/write)
type TemperatureFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewTemperatureFile creates the temperature file
func NewTemperatureFile(client *llm.Client) *TemperatureFile {
func NewTemperatureFile(client llm.Backend) *TemperatureFile {
	return &TemperatureFile{
		BaseFile: protocol.NewBaseFile("temperature", 0666),
		client:   client,

M internal/llmfs/stream.go => internal/llmfs/stream.go +4 -4
@@ 14,11 14,11 @@ import (
// Returns EOF when the stream is complete
type ChunkFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewChunkFile creates the stream/chunk file
func NewChunkFile(client *llm.Client) *ChunkFile {
func NewChunkFile(client llm.Backend) *ChunkFile {
	return &ChunkFile{
		BaseFile: protocol.NewBaseFile("chunk", 0444),
		client:   client,


@@ 58,11 58,11 @@ func (f *ChunkFile) Stat() protocol.Stat {
// Write a prompt to start streaming, then read chunks from stream/chunk
type StreamAskFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewStreamAskFile creates the stream/ask file
func NewStreamAskFile(client *llm.Client) *StreamAskFile {
func NewStreamAskFile(client llm.Backend) *StreamAskFile {
	return &StreamAskFile{
		BaseFile: protocol.NewBaseFile("ask", 0222), // write-only
		client:   client,

M internal/llmfs/tokens.go => internal/llmfs/tokens.go +2 -2
@@ 11,11 11,11 @@ import (
// TokensFile exposes the last response token count (read-only)
type TokensFile struct {
	*protocol.BaseFile
	client *llm.Client
	client llm.Backend
}

// NewTokensFile creates the tokens file
func NewTokensFile(client *llm.Client) *TokensFile {
func NewTokensFile(client llm.Backend) *TokensFile {
	return &TokensFile{
		BaseFile: protocol.NewBaseFile("tokens", 0444),
		client:   client,