Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions cmd/memoria/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package main

import (
"errors"
"fmt"
"strings"
"time"

"github.com/premex-ab/memoria-cli/internal/api"
"github.com/premex-ab/memoria-cli/internal/auth"
"github.com/premex-ab/memoria-cli/internal/config"
"github.com/spf13/cobra"
)

func newInitCmd() *cobra.Command {
var apiURL string

cmd := &cobra.Command{
Use: "init <token>",
Short: "Bind a Memoria API token to your Claude Code sessions",
Long: `memoria init validates the supplied token against the Memoria API,
stores it securely, writes the MCP entry into ~/.claude.json, and records
the bound tenant and brain in ~/.config/memoria/state.json.

Resolution for token storage:
1. If $MEMORIA_API_KEY is already set, env-var mode is used (no persistent write).
2. On macOS, the token is stored in the system keychain.
3. Otherwise, the token is written to ~/.config/memoria/credentials (0600).

The MCP entry added to ~/.claude.json uses Claude Code's headersHelper
mechanism — the token itself is never stored in ~/.claude.json.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
token := args[0]
ctx := cmd.Context()

stdout := cmd.OutOrStdout()
stderr := cmd.ErrOrStderr()

// 1. Validate the token by calling GET /v1/whoami.
client := api.New(apiURL)
whoami, err := client.Whoami(ctx, token)
if err != nil {
if errors.Is(err, api.ErrInvalidToken) {
fmt.Fprintln(stderr,
"memoria: token rejected by server — check that you minted it at",
"https://memoria.premex.se/dashboard and didn't revoke it.",
)
return err
}
fmt.Fprintf(stderr, "memoria: whoami request failed: %v\n", err)
return err
}

// 2. Print identity so the user can confirm what they bound to.
scopes := strings.Join(whoami.Scopes, ",")
fmt.Fprintf(stdout, "Bound to tenant=%s brain=%s scopes=%s\n",
whoami.TenantID, whoami.BrainID, scopes)

// 3. Store the token.
source, err := auth.Store(token)
if err != nil {
fmt.Fprintf(stderr, "memoria: failed to store token: %v\n", err)
return err
}

// 4. Write the MCP entry into ~/.claude.json.
claudePath, err := config.ClaudeJSONPath()
if err != nil {
fmt.Fprintf(stderr, "memoria: cannot find claude.json path: %v\n", err)
return err
}
entry := config.McpEntry{
Type: "http",
URL: apiURL + "/mcp",
HeadersHelper: "memoria headers",
}
if err := config.WriteMemoriaEntry(claudePath, entry); err != nil {
fmt.Fprintf(stderr, "memoria: failed to write ~/.claude.json: %v\n", err)
fmt.Fprintln(stderr, "memoria: the token was stored successfully — re-run 'memoria init <token>' to retry the MCP config step.")
return err
}

// 5. Update state.
state, err := config.Read()
if err != nil {
// Non-fatal: start from a zero state.
state = config.State{}
}
now := time.Now().UTC()
if state.InstalledAt.IsZero() {
state.InstalledAt = now
}
state.LastUpdated = now
state.BoundTenant = whoami.TenantID
state.BoundBrain = whoami.BrainID
state.TokenSource = source.String()

if err := config.Write(state); err != nil {
// Warn but don't fail — the MCP entry and token are already written.
fmt.Fprintf(stderr, "memoria: warning: failed to update state file: %v\n", err)
}

// 6. Done.
fmt.Fprintln(stdout, "Done. Open a Claude Code session and try recall('hello world').")
return nil
},
}

cmd.Flags().StringVar(&apiURL, "api-url", "https://api.memoria.premex.se",
"Memoria API base URL (override for testing or self-hosted instances)")

return cmd
}
158 changes: 158 additions & 0 deletions cmd/memoria/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

// fakeWhoamiServer returns a test server that asserts the inbound Authorization
// header matches "Bearer <expectedToken>" (responding 401 otherwise) and replies
// 200 with the given identity JSON on success.
func fakeWhoamiServer(t *testing.T, expectedToken, tenantID, brainID string, scopes []string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/whoami" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer "+expectedToken {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"missing or invalid Authorization header"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"tenantId": tenantID,
"brainId": brainID,
"scopes": scopes,
"kind": "live",
})
}))
}

// fake401Server returns a test server that always replies 401.
func fake401Server(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":{"code":"invalid_key","message":"unknown or revoked api key"}}`))
}))
}

// runInit executes the init command with the supplied arguments and returns
// stdout, stderr, and any error.
func runInit(t *testing.T, args ...string) (stdout, stderr string, err error) {
t.Helper()

var stdoutBuf, stderrBuf bytes.Buffer
root := newRootCmd()
root.SetOut(&stdoutBuf)
root.SetErr(&stderrBuf)

root.SetArgs(append([]string{"init"}, args...))
err = root.Execute()
return stdoutBuf.String(), stderrBuf.String(), err
}

func TestInit_Success(t *testing.T) {
// Point HOME at a temp dir so all file writes are isolated.
home := t.TempDir()
t.Setenv("HOME", home)
// Clear any token env var so the file backend is used.
t.Setenv("MEMORIA_API_KEY", "")

srv := fakeWhoamiServer(t, "mem_live_testtoken", "t", "b", []string{"memory:read"})
defer srv.Close()

stdout, _, err := runInit(t, "mem_live_testtoken", "--api-url", srv.URL)
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}

// Stdout must confirm the bound identity.
if !strings.Contains(stdout, "Bound to tenant=t") {
t.Errorf("expected stdout to contain 'Bound to tenant=t', got: %q", stdout)
}
if !strings.Contains(stdout, "brain=b") {
t.Errorf("expected stdout to contain 'brain=b', got: %q", stdout)
}

// ~/.claude.json must have the memoria MCP entry.
claudePath := filepath.Join(home, ".claude.json")
raw, err := os.ReadFile(claudePath)
if err != nil {
t.Fatalf("expected ~/.claude.json to exist, got: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("expected valid JSON in ~/.claude.json, got: %v", err)
}
servers, ok := doc["mcpServers"].(map[string]any)
if !ok {
t.Fatal("expected mcpServers to be a map")
}
mem, ok := servers["memoria"]
if !ok {
t.Fatal("expected mcpServers.memoria to be present")
}
memMap, ok := mem.(map[string]any)
if !ok {
t.Fatalf("expected memoria entry to be a map, got %T", mem)
}
expectedURL := srv.URL + "/mcp"
if memMap["url"] != expectedURL {
t.Errorf("expected url=%s, got %v", expectedURL, memMap["url"])
}

// ~/.config/memoria/state.json must have BoundTenant and BoundBrain.
statePath := filepath.Join(home, ".config", "memoria", "state.json")
stateRaw, err := os.ReadFile(statePath)
if err != nil {
t.Fatalf("expected state.json to exist, got: %v", err)
}
var state map[string]any
if err := json.Unmarshal(stateRaw, &state); err != nil {
t.Fatalf("expected valid JSON in state.json, got: %v", err)
}
if state["bound_tenant"] != "t" {
t.Errorf("expected bound_tenant=t, got %v", state["bound_tenant"])
}
if state["bound_brain"] != "b" {
t.Errorf("expected bound_brain=b, got %v", state["bound_brain"])
}
}

func TestInit_InvalidToken_NonZeroExitAndStderr(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("MEMORIA_API_KEY", "")

srv := fake401Server(t)
defer srv.Close()

_, stderr, err := runInit(t, "mem_live_badtoken", "--api-url", srv.URL)
if err == nil {
t.Fatal("expected non-zero exit for 401, got nil error")
}

// Stderr must contain a useful message.
if !strings.Contains(stderr, "token rejected") {
t.Errorf("expected stderr to mention 'token rejected', got: %q", stderr)
}

// ~/.claude.json must NOT be written.
claudePath := filepath.Join(home, ".claude.json")
if _, err := os.Stat(claudePath); !errors.Is(err, os.ErrNotExist) {
t.Errorf("expected ~/.claude.json to NOT exist after 401, but it does")
}
}
1 change: 1 addition & 0 deletions cmd/memoria/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ and 'memoria headers' to resolve the active API token as an HTTP header.`,
root.Version = version.Version

root.AddCommand(newHeadersCmd())
root.AddCommand(newInitCmd())

return root
}
Expand Down
Empty file removed internal/api/.gitkeep
Empty file.
102 changes: 102 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Package api provides a thin HTTP client for the Memoria API endpoints used by the CLI.
package api

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"

"github.com/premex-ab/memoria-cli/internal/version"
)

// ErrInvalidToken is the sentinel error returned (wrapped in AuthError) when
// the server responds with HTTP 401.
var ErrInvalidToken = errors.New("invalid or revoked token")

// AuthError is returned when the Memoria API rejects the supplied token.
// It wraps ErrInvalidToken so callers can use errors.Is.
type AuthError struct {
// Body is the raw response body from the server.
Body string
}

func (e *AuthError) Error() string {
return fmt.Sprintf("token rejected by server: %s", e.Body)
}

// Is implements the errors.Is interface so that errors.Is(err, ErrInvalidToken)
// returns true when err is an *AuthError.
func (e *AuthError) Is(target error) bool {
return target == ErrInvalidToken
}

// WhoamiResponse is the parsed body from GET /v1/whoami.
// JSON tags use camelCase to match the server's response shape exactly.
type WhoamiResponse struct {
TenantID string `json:"tenantId"`
BrainID string `json:"brainId"`
Scopes []string `json:"scopes"`
Kind string `json:"kind"`
}

// Client is a minimal HTTP client for the Memoria API.
type Client struct {
BaseURL string
HTTPClient *http.Client
}

// New returns a Client targeting baseURL with a 10-second timeout.
func New(baseURL string) *Client {
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}

// Whoami calls GET {BaseURL}/v1/whoami with the supplied token in the
// Authorization header and returns the parsed identity response.
//
// On 401, it returns a typed *AuthError wrapping ErrInvalidToken.
// On other non-2xx responses it returns a generic error with the status and body.
func (c *Client) Whoami(ctx context.Context, token string) (*WhoamiResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/v1/whoami", nil)
if err != nil {
return nil, fmt.Errorf("whoami: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "memoria-cli/"+version.Version)

resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("whoami: request: %w", err)
}
defer resp.Body.Close()

// Limit the response body to 64 KiB to guard against a misbehaving server
// returning a huge error body that would OOM the CLI.
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, fmt.Errorf("whoami: read body: %w", err)
}
body := string(bodyBytes)

if resp.StatusCode == http.StatusUnauthorized {
return nil, &AuthError{Body: body}
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("whoami: HTTP %d: %s", resp.StatusCode, body)
}

var result WhoamiResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return nil, fmt.Errorf("whoami: decode response: %w", err)
}
return &result, nil
}
Loading
Loading