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,