diff --git a/Makefile b/Makefile index 82222f7..9dbda9e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: build fmt check clean all # Go binaries to build -BINARIES := bash_tool chat edit_tool list_files read +BINARIES := bash_tool chat edit_tool list_files read server # Build all binaries build: @@ -11,6 +11,7 @@ build: go build -o edit_tool edit_tool.go go build -o list_files list_files.go go build -o read read.go + go build -o server server.go # Format all Go files fmt: diff --git a/README.md b/README.md index ccb6532..e2c8f12 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,53 @@ go run code_search_tool.go ## 🛠️ Tool System Architecture +### Authentication & Subscriptions + +The server now supports JWT-based authentication and a subscription paywall backed by Stripe. + +Endpoints: + +- POST `/api/signup`: `{ email, password }` → `{ token, user }` +- POST `/api/login`: `{ email, password }` → `{ token, user }` +- GET `/api/me`: Requires `Authorization: Bearer ` → `{ email, subscription_ok }` +- POST `/api/checkout`: Requires auth → returns `{ url }` to Stripe Checkout +- POST `/api/portal`: Requires auth → returns `{ url }` to Stripe Billing Portal +- POST `/api/stripe/webhook`: Stripe event receiver (configure your webhook to point here) + +Paywall: + +- The chat endpoint `POST /api/message` now requires a valid JWT and an active subscription. It returns HTTP 401 if unauthenticated and 402 if a subscription is required. + +Frontend: + +- Minimal controls added to the header to login, signup, subscribe, manage billing, and logout. Token is persisted in `localStorage` and sent as `Authorization: Bearer` for API calls. + +Environment variables: + +- `JWT_SECRET` (required): HMAC secret for signing JWTs +- `USER_STORE_PATH` (optional, default `data/users.json`) +- `STRIPE_SECRET_KEY` (required for billing) +- `STRIPE_PRICE_ID` (required): The Price ID for your subscription product +- `STRIPE_WEBHOOK_SECRET` (recommended): Validates webhook signatures +- `PUBLIC_URL` (optional): Base URL for success/return links, default `/` + +Run the server: + +```bash +JWT_SECRET=change-me \ +STRIPE_SECRET_KEY=sk_test_... \ +STRIPE_PRICE_ID=price_... \ +STRIPE_WEBHOOK_SECRET=whsec_... \ +go build -o server server.go auth.go billing.go && ./server --verbose +``` + +Stripe webhook (example with Stripe CLI): + +```bash +stripe listen --events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:8080/api/stripe/webhook +``` + + The tool system uses a consistent pattern across all applications: ```mermaid diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..cda70b5 --- /dev/null +++ b/auth.go @@ -0,0 +1,183 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// User represents an application user. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Roles []string `json:"roles,omitempty"` + SubscriptionOK bool `json:"subscription_ok"` + StripeCustomerID string `json:"stripe_customer_id,omitempty"` + CreatedAt int64 `json:"created_at"` +} + +// UserStore provides thread-safe CRUD access to users backed by a JSON file. +type UserStore struct { + filePath string + mu sync.RWMutex + byEmail map[string]*User +} + +func NewUserStore(filePath string) (*UserStore, error) { + us := &UserStore{filePath: filePath, byEmail: map[string]*User{}} + if err := us.load(); err != nil { + if errors.Is(err, os.ErrNotExist) { + // Ensure directory exists + _ = os.MkdirAll(filepath.Dir(filePath), 0o755) + if err := us.save(); err != nil { return nil, err } + } else { + return nil, err + } + } + return us, nil +} + +func (s *UserStore) load() error { + b, err := os.ReadFile(s.filePath) + if err != nil { return err } + var users []*User + if err := json.Unmarshal(b, &users); err != nil { return err } + for _, u := range users { + s.byEmail[strings.ToLower(u.Email)] = u + } + return nil +} + +func (s *UserStore) save() error { + s.mu.RLock() + defer s.mu.RUnlock() + var users []*User + for _, u := range s.byEmail { + users = append(users, u) + } + b, err := json.MarshalIndent(users, "", " ") + if err != nil { return err } + return os.WriteFile(s.filePath, b, 0o600) +} + +func (s *UserStore) CreateUser(email, plaintextPassword string) (*User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" || plaintextPassword == "" { return nil, fmt.Errorf("email and password required") } + s.mu.Lock(); defer s.mu.Unlock() + if _, exists := s.byEmail[email]; exists { return nil, fmt.Errorf("user already exists") } + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), bcrypt.DefaultCost) + if err != nil { return nil, err } + id := generateDeterministicID(email) + u := &User{ID: id, Email: email, PasswordHash: string(hash), Roles: []string{"user"}, SubscriptionOK: false, CreatedAt: time.Now().Unix()} + s.byEmail[email] = u + if err := s.save(); err != nil { return nil, err } + return u, nil +} + +func (s *UserStore) Authenticate(email, plaintextPassword string) (*User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + s.mu.RLock(); u := s.byEmail[email]; s.mu.RUnlock() + if u == nil { return nil, fmt.Errorf("invalid credentials") } + if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(plaintextPassword)); err != nil { return nil, fmt.Errorf("invalid credentials") } + return u, nil +} + +func (s *UserStore) GetByEmail(email string) *User { + email = strings.ToLower(strings.TrimSpace(email)) + s.mu.RLock(); defer s.mu.RUnlock() + return s.byEmail[email] +} + +func (s *UserStore) SetSubscription(email string, ok bool) error { + email = strings.ToLower(strings.TrimSpace(email)) + s.mu.Lock(); defer s.mu.Unlock() + u := s.byEmail[email] + if u == nil { return fmt.Errorf("user not found") } + u.SubscriptionOK = ok + return s.save() +} + +func (s *UserStore) SetStripeCustomerID(email, customerID string) error { + email = strings.ToLower(strings.TrimSpace(email)) + s.mu.Lock(); defer s.mu.Unlock() + u := s.byEmail[email] + if u == nil { return fmt.Errorf("user not found") } + u.StripeCustomerID = customerID + return s.save() +} + +func (s *UserStore) FindByStripeCustomerID(customerID string) *User { + s.mu.RLock(); defer s.mu.RUnlock() + for _, u := range s.byEmail { + if u.StripeCustomerID == customerID { return u } + } + return nil +} + +func generateDeterministicID(seed string) string { + h := sha256.Sum256([]byte(seed)) + return base64.RawURLEncoding.EncodeToString(h[:16]) +} + +// JWT utilities (HMAC-SHA256) + +type JWTClaims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Exp int64 `json:"exp"` +} + +func getJWTSecret() ([]byte, error) { + secret := strings.TrimSpace(os.Getenv("JWT_SECRET")) + if secret == "" { return nil, fmt.Errorf("JWT_SECRET not set") } + return []byte(secret), nil +} + +func createJWT(claims JWTClaims) (string, error) { + head := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payloadBytes, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + unsigned := head + "." + payload + secret, err := getJWTSecret() + if err != nil { return "", err } + h := hmac.New(sha256.New, secret) + h.Write([]byte(unsigned)) + sig := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + return unsigned + "." + sig, nil +} + +func parseAndValidateJWT(token string) (*JWTClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { return nil, fmt.Errorf("invalid token format") } + unsigned := parts[0] + "." + parts[1] + secret, err := getJWTSecret() + if err != nil { return nil, err } + h := hmac.New(sha256.New, secret) + h.Write([]byte(unsigned)) + expected := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + if !constantTimeEqual(parts[2], expected) { return nil, fmt.Errorf("invalid signature") } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { return nil, err } + var claims JWTClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { return nil, err } + if time.Now().Unix() > claims.Exp { return nil, fmt.Errorf("token expired") } + return &claims, nil +} + +func constantTimeEqual(a, b string) bool { + if len(a) != len(b) { return false } + var v byte + for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } + return v == 0 +} \ No newline at end of file diff --git a/billing.go b/billing.go new file mode 100644 index 0000000..b6dd9e8 --- /dev/null +++ b/billing.go @@ -0,0 +1,146 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + stripe "github.com/stripe/stripe-go/v76" + portal "github.com/stripe/stripe-go/v76/billingportal/session" + checkout "github.com/stripe/stripe-go/v76/checkout/session" + "github.com/stripe/stripe-go/v76/customer" + "github.com/stripe/stripe-go/v76/webhook" +) + +func (s *Server) initStripeFromEnv() { + key := strings.TrimSpace(os.Getenv("STRIPE_SECRET_KEY")) + if key != "" { + stripe.Key = key + } +} + +func (s *Server) handleCheckout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + user, err := s.getUserFromRequest(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + priceID := strings.TrimSpace(os.Getenv("STRIPE_PRICE_ID")) + if priceID == "" { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "STRIPE_PRICE_ID not configured"}) + return + } + publicURL := strings.TrimSpace(os.Getenv("PUBLIC_URL")) + if publicURL == "" { publicURL = "/" } + customerID := user.StripeCustomerID + if customerID == "" { + params := &stripe.CustomerParams{Email: stripe.String(user.Email)} + cust, err := customer.New(params) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("create customer failed: %v", err)}) + return + } + customerID = cust.ID + _ = s.userStore.SetStripeCustomerID(user.Email, customerID) + } + cs, err := checkout.New(&stripe.CheckoutSessionParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + Customer: stripe.String(customerID), + LineItems: []*stripe.CheckoutSessionLineItemParams{{Price: stripe.String(priceID), Quantity: stripe.Int64(1)}}, + SuccessURL: stripe.String(publicURL + "?checkout=success"), + CancelURL: stripe.String(publicURL + "?checkout=cancel"), + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("checkout failed: %v", err)}) + return + } + writeJSON(w, map[string]string{"url": cs.URL}) +} + +func (s *Server) handlePortal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + user, err := s.getUserFromRequest(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + if user.StripeCustomerID == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "no Stripe customer"}) + return + } + publicURL := strings.TrimSpace(os.Getenv("PUBLIC_URL")) + if publicURL == "" { publicURL = "/" } + ps, err := portal.New(&stripe.BillingPortalSessionParams{ + Customer: stripe.String(user.StripeCustomerID), + ReturnURL: stripe.String(publicURL), + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("portal failed: %v", err)}) + return + } + writeJSON(w, map[string]string{"url": ps.URL}) +} + +func (s *Server) handleStripeWebhook(w http.ResponseWriter, r *http.Request) { + payload, err := io.ReadAll(r.Body) + if err != nil { w.WriteHeader(http.StatusBadRequest); return } + endpointSecret := strings.TrimSpace(os.Getenv("STRIPE_WEBHOOK_SECRET")) + var event stripe.Event + if endpointSecret == "" { + if err := json.Unmarshal(payload, &event); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + } else { + sig := r.Header.Get("Stripe-Signature") + event, err = webhook.ConstructEvent(payload, sig, endpointSecret) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + } + switch event.Type { + case "checkout.session.completed": + var cs stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &cs); err == nil { + email := strings.TrimSpace(cs.CustomerEmail) + if email == "" && cs.Customer != nil { + if u := s.userStore.FindByStripeCustomerID(cs.Customer.ID); u != nil { email = u.Email } + } + if email != "" { _ = s.userStore.SetSubscription(email, true) } + } + case "customer.subscription.created", "customer.subscription.updated", "invoice.paid": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err == nil { + u := s.userStore.FindByStripeCustomerID(sub.Customer.ID) + if u != nil { + ok := sub.Status == stripe.SubscriptionStatusActive || sub.Status == stripe.SubscriptionStatusTrialing + _ = s.userStore.SetSubscription(u.Email, ok) + } + } + case "customer.subscription.deleted", "invoice.payment_failed": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err == nil { + u := s.userStore.FindByStripeCustomerID(sub.Customer.ID) + if u != nil { _ = s.userStore.SetSubscription(u.Email, false) } + } + } + w.WriteHeader(http.StatusOK) +} \ No newline at end of file diff --git a/code_search b/code_search new file mode 100755 index 0000000..b74a7ba Binary files /dev/null and b/code_search differ diff --git a/code_search_tool.go b/code_search_tool.go index acbdb6b..fb2bb28 100644 --- a/code_search_tool.go +++ b/code_search_tool.go @@ -309,10 +309,10 @@ type BashInput struct { var BashInputSchema = GenerateSchema[BashInput]() type CodeSearchInput struct { - Pattern string `json:"pattern" jsonschema_description:"The search pattern or regex to look for"` - Path string `json:"path,omitempty" jsonschema_description:"Optional path to search in (file or directory)"` - FileType string `json:"file_type,omitempty" jsonschema_description:"Optional file extension to limit search to (e.g., 'go', 'js', 'py')"` - CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema_description:"Whether the search should be case sensitive (default: false)"` + Pattern string `json:"pattern" jsonschema_description:"The search pattern or regex to look for"` + Path string `json:"path,omitempty" jsonschema_description:"Optional path to search in (file or directory)"` + FileType string `json:"file_type,omitempty" jsonschema_description:"Optional file extension to limit search to (e.g., 'go', 'js', 'py')"` + CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema_description:"Whether the search should be case sensitive (default: false)"` } var CodeSearchInputSchema = GenerateSchema[CodeSearchInput]() @@ -430,7 +430,7 @@ func CodeSearch(input json.RawMessage) (string, error) { cmd := exec.Command(args[0], args[1:]...) output, err := cmd.Output() - + // ripgrep returns exit code 1 when no matches are found, which is not an error if err != nil { if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 { @@ -443,14 +443,14 @@ func CodeSearch(input json.RawMessage) (string, error) { result := strings.TrimSpace(string(output)) lines := strings.Split(result, "\n") - + log.Printf("Found %d matches for pattern: %s", len(lines), codeSearchInput.Pattern) - + // Limit output to prevent overwhelming responses if len(lines) > 50 { result = strings.Join(lines[:50], "\n") + fmt.Sprintf("\n... (showing first 50 of %d matches)", len(lines)) } - + return result, nil } diff --git a/go.mod b/go.mod index e22fd45..33f1c19 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.24.2 require ( github.com/anthropics/anthropic-sdk-go v1.6.2 github.com/invopop/jsonschema v0.13.0 + github.com/stripe/stripe-go/v76 v76.25.0 + golang.org/x/crypto v0.41.0 ) require ( diff --git a/go.sum b/go.sum index e9c518d..034ae93 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +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/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -13,8 +14,12 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stripe/stripe-go/v76 v76.25.0 h1:kmDoOTvdQSTQssQzWZQQkgbAR2Q8eXdMWbN/ylNalWA= +github.com/stripe/stripe-go/v76 v76.25.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -27,7 +32,20 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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= diff --git a/server b/server new file mode 100755 index 0000000..bb36a27 Binary files /dev/null and b/server differ diff --git a/server.go b/server.go new file mode 100644 index 0000000..945ac8c --- /dev/null +++ b/server.go @@ -0,0 +1,691 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/invopop/jsonschema" +) + +type Server struct { + client *anthropic.Client + tools []ToolDefinition + verbose bool + sessions map[string][]anthropic.MessageParam + sessionsMu sync.RWMutex + model anthropic.Model + maxTokens int64 + userStore *UserStore +} + +type MessageRequest struct { + SessionID string `json:"session_id"` + Message string `json:"message"` +} + +type Event struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Tool string `json:"tool,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type MessageResponse struct { + Events []Event `json:"events"` + Final string `json:"final"` +} + +func main() { + addr := flag.String("addr", ":8080", "address to listen on") + verbose := flag.Bool("verbose", false, "enable verbose logging") + modelFlag := flag.String("model", "", "override model (default: env ANTHROPIC_MODEL or Claude 3.7 Sonnet)") + maxTokensFlag := flag.Int("max_tokens", 1024, "max tokens for responses") + flag.Parse() + + if *verbose { + log.SetOutput(os.Stderr) + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("Verbose logging enabled") + } else { + log.SetOutput(os.Stdout) + log.SetFlags(0) + log.SetPrefix("") + } + + client := anthropic.NewClient() + if *verbose { + log.Println("Anthropic client initialized") + } + + // Resolve model and max tokens from flags/env + model := anthropic.Model(strings.TrimSpace(*modelFlag)) + if model == "" { + model = anthropic.Model(strings.TrimSpace(os.Getenv("ANTHROPIC_MODEL"))) + } + if model == "" { + model = anthropic.ModelClaude3_7SonnetLatest + } + + maxTokens := int64(*maxTokensFlag) + if envMax := strings.TrimSpace(os.Getenv("MAX_TOKENS")); envMax != "" { + if v, err := strconv.Atoi(envMax); err == nil { + maxTokens = int64(v) + } + } + + tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition, BashDefinition, EditFileDefinition, CodeSearchDefinition} + if *verbose { + log.Printf("Initialized %d tools", len(tools)) + } + + // Initialize user store + userStorePath := strings.TrimSpace(os.Getenv("USER_STORE_PATH")) + if userStorePath == "" { + userStorePath = "data/users.json" + } + us, err := NewUserStore(userStorePath) + if err != nil { + log.Fatalf("failed to init user store: %v", err) + } + + s := &Server{ + client: &client, + tools: tools, + verbose: *verbose, + sessions: make(map[string][]anthropic.MessageParam), + model: model, + maxTokens: maxTokens, + userStore: us, + } + + // Init Stripe if configured + s.initStripeFromEnv() + + http.HandleFunc("/api/session", s.handleNewSession) + http.HandleFunc("/api/message", s.handleMessage) + + // Auth endpoints + http.HandleFunc("/api/signup", s.handleSignup) + http.HandleFunc("/api/login", s.handleLogin) + http.HandleFunc("/api/me", s.handleMe) + + // Billing endpoints + http.HandleFunc("/api/checkout", s.handleCheckout) + http.HandleFunc("/api/portal", s.handlePortal) + http.HandleFunc("/api/stripe/webhook", s.handleStripeWebhook) + + // Serve static UI from ./web + fs := http.FileServer(http.Dir("web")) + http.Handle("/", fs) + + log.Printf("Server listening on %s (model=%s, max_tokens=%d)", *addr, s.model, s.maxTokens) + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + id := generateID() + s.sessionsMu.Lock() + s.sessions[id] = []anthropic.MessageParam{} + s.sessionsMu.Unlock() + writeJSON(w, map[string]string{"session_id": id}) +} + +func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + // Require authenticated and subscribed user + user, err := s.getUserFromRequest(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + if !user.SubscriptionOK { + w.WriteHeader(http.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "subscription required"}) + return + } + defer r.Body.Close() + var req MessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request"}) + return + } + if strings.TrimSpace(req.SessionID) == "" || strings.TrimSpace(req.Message) == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "session_id and message are required"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + resp, err := s.processUserMessage(ctx, req.SessionID, req.Message) + if err != nil { + if s.verbose { + log.Printf("process error: %v", err) + } + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + writeJSON(w, resp) +} + +// ----------- +// Auth & Utils +// ----------- + +type authRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + defer r.Body.Close() + var req authRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "email and password required"}) + return + } + user, err := s.userStore.CreateUser(req.Email, req.Password) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + claims := JWTClaims{Subject: user.ID, Email: user.Email, Exp: time.Now().Add(30 * 24 * time.Hour).Unix()} + tok, err := createJWT(claims) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to create token"}) + return + } + writeJSON(w, map[string]any{"token": tok, "user": map[string]any{"email": user.Email, "subscription_ok": user.SubscriptionOK}}) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + defer r.Body.Close() + var req authRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" || strings.TrimSpace(req.Password) == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "email and password required"}) + return + } + user, err := s.userStore.Authenticate(req.Email, req.Password) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid credentials"}) + return + } + claims := JWTClaims{Subject: user.ID, Email: user.Email, Exp: time.Now().Add(30 * 24 * time.Hour).Unix()} + tok, err := createJWT(claims) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to create token"}) + return + } + writeJSON(w, map[string]any{"token": tok, "user": map[string]any{"email": user.Email, "subscription_ok": user.SubscriptionOK}}) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + user, err := s.getUserFromRequest(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + writeJSON(w, map[string]any{"email": user.Email, "subscription_ok": user.SubscriptionOK}) +} + +func (s *Server) getUserFromRequest(r *http.Request) (*User, error) { + auth := r.Header.Get("Authorization") + if auth == "" { return nil, fmt.Errorf("no auth header") } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { return nil, fmt.Errorf("invalid auth header") } + claims, err := parseAndValidateJWT(parts[1]) + if err != nil { return nil, err } + user := s.userStore.GetByEmail(claims.Email) + if user == nil { return nil, fmt.Errorf("user not found") } + return user, nil +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) processUserMessage(ctx context.Context, sessionID, userInput string) (*MessageResponse, error) { + s.sessionsMu.RLock() + conversation := append([]anthropic.MessageParam(nil), s.sessions[sessionID]...) + s.sessionsMu.RUnlock() + + var events []Event + finalText := "" + + userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)) + conversation = append(conversation, userMessage) + + message, err := s.runInference(ctx, conversation) + if err != nil { + return nil, err + } + conversation = append(conversation, message.ToParam()) + + for { + var toolResults []anthropic.ContentBlockParamUnion + hasToolUse := false + + for _, content := range message.Content { + switch content.Type { + case "text": + events = append(events, Event{Type: "assistant", Text: content.Text}) + finalText += content.Text + "\n" + case "tool_use": + hasToolUse = true + toolUse := content.AsToolUse() + if s.verbose { + log.Printf("Tool use: %s input=%s", toolUse.Name, string(toolUse.Input)) + } + events = append(events, Event{Type: "tool", Tool: toolUse.Name, Input: toolUse.Input}) + + var toolResult string + var toolErr error + var found bool + for _, tool := range s.tools { + if tool.Name == toolUse.Name { + toolResult, toolErr = tool.Function(toolUse.Input) + found = true + break + } + } + if !found { + toolErr = fmt.Errorf("tool '%s' not found", toolUse.Name) + } + + if toolErr != nil { + events = append(events, Event{Type: "error", Tool: toolUse.Name, Error: toolErr.Error()}) + toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, toolErr.Error(), true)) + } else { + events = append(events, Event{Type: "result", Tool: toolUse.Name, Result: toolResult}) + toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, toolResult, false)) + } + } + } + + if !hasToolUse { + break + } + + toolResultMessage := anthropic.NewUserMessage(toolResults...) + conversation = append(conversation, toolResultMessage) + + message, err = s.runInference(ctx, conversation) + if err != nil { + return nil, err + } + conversation = append(conversation, message.ToParam()) + } + + // Save updated conversation + s.sessionsMu.Lock() + s.sessions[sessionID] = conversation + s.sessionsMu.Unlock() + + return &MessageResponse{Events: events, Final: strings.TrimSpace(finalText)}, nil +} + +func (s *Server) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) { + anthropicTools := []anthropic.ToolUnionParam{} + for _, tool := range s.tools { + anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + Name: tool.Name, + Description: anthropic.String(tool.Description), + InputSchema: tool.InputSchema, + }, + }) + } + + if s.verbose { + log.Printf("Calling Claude (model=%s, tools=%d)", s.model, len(anthropicTools)) + } + + message, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: s.model, + MaxTokens: s.maxTokens, + Messages: conversation, + Tools: anthropicTools, + }) + + if s.verbose { + if err != nil { + log.Printf("API error: %v", err) + } else { + log.Printf("API call successful") + } + } + + return message, err +} + +func generateID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// ----------------------- +// Tooling implementation +// ----------------------- + +type ToolDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` + Function func(input json.RawMessage) (string, error) +} + +var ReadFileDefinition = ToolDefinition{ + Name: "read_file", + Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.", + InputSchema: ReadFileInputSchema, + Function: ReadFile, +} + +var ListFilesDefinition = ToolDefinition{ + Name: "list_files", + Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.", + InputSchema: ListFilesInputSchema, + Function: ListFiles, +} + +var BashDefinition = ToolDefinition{ + Name: "bash", + Description: "Execute a bash command and return its output. Use this to run shell commands.", + InputSchema: BashInputSchema, + Function: Bash, +} + +var EditFileDefinition = ToolDefinition{ + Name: "edit_file", + Description: `Make edits to a text file. + +Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other. + +If the file specified with path doesn't exist, it will be created. +`, + InputSchema: EditFileInputSchema, + Function: EditFile, +} + +var CodeSearchDefinition = ToolDefinition{ + Name: "code_search", + Description: "Search for code patterns using ripgrep (rg).", + InputSchema: CodeSearchInputSchema, + Function: CodeSearch, +} + +type ReadFileInput struct { + Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."` +} + +var ReadFileInputSchema = GenerateSchema[ReadFileInput]() + +type ListFilesInput struct { + Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."` +} + +var ListFilesInputSchema = GenerateSchema[ListFilesInput]() + +type BashInput struct { + Command string `json:"command" jsonschema_description:"The bash command to execute."` +} + +var BashInputSchema = GenerateSchema[BashInput]() + +type EditFileInput struct { + Path string `json:"path" jsonschema_description:"The path to the file"` + OldStr string `json:"old_str" jsonschema_description:"Text to search for - must match exactly and must only have one match exactly"` + NewStr string `json:"new_str" jsonschema_description:"Text to replace old_str with"` +} + +var EditFileInputSchema = GenerateSchema[EditFileInput]() + +type CodeSearchInput struct { + Pattern string `json:"pattern" jsonschema_description:"The search pattern or regex to look for"` + Path string `json:"path,omitempty" jsonschema_description:"Optional path to search in (file or directory)"` + FileType string `json:"file_type,omitempty" jsonschema_description:"Optional file extension to limit search to (e.g., 'go', 'js', 'py')"` + CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema_description:"Whether the search should be case sensitive (default: false)"` +} + +var CodeSearchInputSchema = GenerateSchema[CodeSearchInput]() + +func ReadFile(input json.RawMessage) (string, error) { + readFileInput := ReadFileInput{} + if err := json.Unmarshal(input, &readFileInput); err != nil { + return "", err + } + content, err := os.ReadFile(readFileInput.Path) + if err != nil { + return "", err + } + return string(content), nil +} + +func ListFiles(input json.RawMessage) (string, error) { + listFilesInput := ListFilesInput{} + if err := json.Unmarshal(input, &listFilesInput); err != nil { + return "", err + } + dir := "." + if listFilesInput.Path != "" { + dir = listFilesInput.Path + } + var files []string + err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + relPath, err := filepath.Rel(dir, p) + if err != nil { + return err + } + if info.IsDir() && (relPath == ".devenv" || strings.HasPrefix(relPath, ".devenv/")) { + return filepath.SkipDir + } + if relPath != "." { + if info.IsDir() { + files = append(files, relPath+"/") + } else { + files = append(files, relPath) + } + } + return nil + }) + if err != nil { + return "", err + } + result, err := json.Marshal(files) + if err != nil { + return "", err + } + return string(result), nil +} + +func Bash(input json.RawMessage) (string, error) { + bashInput := BashInput{} + if err := json.Unmarshal(input, &bashInput); err != nil { + return "", err + } + cmd := exec.Command("bash", "-c", bashInput.Command) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Sprintf("Command failed with error: %s\nOutput: %s", err.Error(), string(output)), nil + } + return strings.TrimSpace(string(output)), nil +} + +func EditFile(input json.RawMessage) (string, error) { + editFileInput := EditFileInput{} + if err := json.Unmarshal(input, &editFileInput); err != nil { + return "", err + } + if editFileInput.Path == "" || editFileInput.OldStr == editFileInput.NewStr { + return "", fmt.Errorf("invalid input parameters") + } + content, err := os.ReadFile(editFileInput.Path) + if err != nil { + if os.IsNotExist(err) && editFileInput.OldStr == "" { + return createNewFile(editFileInput.Path, editFileInput.NewStr) + } + return "", err + } + oldContent := string(content) + var newContent string + if editFileInput.OldStr == "" { + newContent = oldContent + editFileInput.NewStr + } else { + count := strings.Count(oldContent, editFileInput.OldStr) + if count == 0 { + return "", fmt.Errorf("old_str not found in file") + } + if count > 1 { + return "", fmt.Errorf("old_str found %d times in file, must be unique", count) + } + newContent = strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, 1) + } + if err := os.WriteFile(editFileInput.Path, []byte(newContent), 0644); err != nil { + return "", err + } + return "OK", nil +} + +func createNewFile(filePathStr, content string) (string, error) { + dir := path.Dir(filePathStr) + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + } + if err := os.WriteFile(filePathStr, []byte(content), 0644); err != nil { + return "", fmt.Errorf("failed to create file: %w", err) + } + return fmt.Sprintf("Successfully created file %s", filePathStr), nil +} + +func CodeSearch(input json.RawMessage) (string, error) { + codeSearchInput := CodeSearchInput{} + if err := json.Unmarshal(input, &codeSearchInput); err != nil { + return "", err + } + if codeSearchInput.Pattern == "" { + return "", fmt.Errorf("pattern is required") + } + // Prefer ripgrep if available + if _, err := exec.LookPath("rg"); err == nil { + args := []string{"rg", "--line-number", "--with-filename", "--color=never"} + if !codeSearchInput.CaseSensitive { + args = append(args, "--ignore-case") + } + if codeSearchInput.FileType != "" { + args = append(args, "--type", codeSearchInput.FileType) + } + args = append(args, codeSearchInput.Pattern) + if codeSearchInput.Path != "" { + args = append(args, codeSearchInput.Path) + } else { + args = append(args, ".") + } + cmd := exec.Command(args[0], args[1:]...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "No matches found", nil + } + return "", fmt.Errorf("search failed: %w", err) + } + result := strings.TrimSpace(string(output)) + lines := strings.Split(result, "\n") + if len(lines) > 50 { + result = strings.Join(lines[:50], "\n") + fmt.Sprintf("\n... (showing first 50 of %d matches)", len(lines)) + } + return result, nil + } + // Fallback minimal grep if ripgrep not available + args := []string{"grep", "-R", "-n"} + if !codeSearchInput.CaseSensitive { + args = append(args, "-i") + } + args = append(args, codeSearchInput.Pattern) + if codeSearchInput.Path != "" { + args = append(args, codeSearchInput.Path) + } else { + args = append(args, ".") + } + cmd := exec.Command(args[0], args[1:]...) + output, err := cmd.CombinedOutput() + if err != nil { + if len(output) == 0 { + return "", err + } + } + result := strings.TrimSpace(string(output)) + lines := strings.Split(result, "\n") + if len(lines) > 50 { + result = strings.Join(lines[:50], "\n") + fmt.Sprintf("\n... (showing first 50 of %d matches)", len(lines)) + } + return result, nil +} + +func GenerateSchema[T any]() anthropic.ToolInputSchemaParam { + reflector := jsonschema.Reflector{ + AllowAdditionalProperties: false, + DoNotReference: true, + } + var v T + schema := reflector.Reflect(v) + return anthropic.ToolInputSchemaParam{ + Properties: schema.Properties, + } +} + diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..1da0d11 --- /dev/null +++ b/web/app.js @@ -0,0 +1,170 @@ +(() => { + const messagesEl = document.getElementById('messages'); + const formEl = document.getElementById('chat-form'); + const inputEl = document.getElementById('input'); + const statusEl = document.getElementById('status'); + const loginBtn = document.getElementById('login-btn'); + const signupBtn = document.getElementById('signup-btn'); + const logoutBtn = document.getElementById('logout-btn'); + const subscribeBtn = document.getElementById('subscribe-btn'); + const portalBtn = document.getElementById('portal-btn'); + const userEmailEl = document.getElementById('user-email'); + + let sessionId = null; + let token = localStorage.getItem('token') || ''; + + function setAuthToken(t) { + token = t || ''; + if (token) localStorage.setItem('token', token); else localStorage.removeItem('token'); + refreshAuthUI(); + } + + async function api(path, opts = {}) { + const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}); + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(path, Object.assign({}, opts, { headers })); + if (res.headers.get('content-type')?.includes('application/json')) { + const data = await res.json(); + return { res, data }; + } + return { res, data: null }; + } + + function appendMessage(role, text) { + const div = document.createElement('div'); + div.className = `msg ${role}`; + document.createTextNode(text); + div.textContent = text; + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function appendTool(name, input) { + const div = document.createElement('div'); + div.className = 'msg tool'; + const pre = document.createElement('pre'); + pre.textContent = `tool: ${name}(${input ? JSON.stringify(input) : ''})`; + div.appendChild(pre); + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function appendResult(name, result) { + const div = document.createElement('div'); + div.className = 'msg result'; + const pre = document.createElement('pre'); + pre.textContent = `result: ${result}`; + div.appendChild(pre); + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function appendError(err) { + const div = document.createElement('div'); + div.className = 'msg error'; + div.textContent = `error: ${err}`; + messagesEl.appendChild(div); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + async function ensureSession() { + if (sessionId) return sessionId; + statusEl.textContent = 'Connecting...'; + const { data } = await api('/api/session', { method: 'POST' }); + sessionId = data.session_id; + statusEl.textContent = 'Connected'; + return sessionId; + } + + async function fetchMe() { + if (!token) { userEmailEl.textContent = ''; return { email: '', subscription_ok: false }; } + try { + const { res, data } = await api('/api/me'); + if (!res.ok) throw new Error('unauthorized'); + userEmailEl.textContent = data.email; + return data; + } catch { + setAuthToken(''); + return { email: '', subscription_ok: false }; + } + } + + async function loginOrSignup(kind) { + const email = prompt('Email:'); + if (!email) return; + const password = prompt('Password:'); + if (!password) return; + const { res, data } = await api(`/api/${kind}`, { method: 'POST', body: JSON.stringify({ email, password }) }); + if (!res.ok) { alert(data?.error || 'Failed'); return; } + setAuthToken(data.token); + appendMessage('assistant', `Auth: ${kind} successful.`); + refreshAuthUI(); + } + + async function startCheckout() { + const { res, data } = await api('/api/checkout', { method: 'POST' }); + if (!res.ok) { alert(data?.error || 'Checkout failed'); return; } + window.location.href = data.url; + } + + async function openPortal() { + const { res, data } = await api('/api/portal', { method: 'POST' }); + if (!res.ok) { alert(data?.error || 'Portal failed'); return; } + window.location.href = data.url; + } + + function refreshAuthUI() { + const authed = !!token; + loginBtn.style.display = authed ? 'none' : ''; + signupBtn.style.display = authed ? 'none' : ''; + logoutBtn.style.display = authed ? '' : 'none'; + subscribeBtn.style.display = authed ? '' : 'none'; + portalBtn.style.display = authed ? '' : 'none'; + } + + formEl.addEventListener('submit', async (e) => { + e.preventDefault(); + const text = inputEl.value.trim(); + if (!text) return; + await ensureSession(); + appendMessage('user', `You: ${text}`); + inputEl.value = ''; + formEl.querySelector('button').disabled = true; + try { + const { res, data } = await api('/api/message', { method: 'POST', body: JSON.stringify({ session_id: sessionId, message: text }) }); + if (!res.ok) { + if (res.status === 402) { + appendError('Subscription required. Click Subscribe to continue.'); + } else if (res.status === 401) { + appendError('Please log in first.'); + } + throw new Error(data?.error || 'Request failed'); + } + for (const ev of data.events) { + if (ev.type === 'assistant') appendMessage('assistant', `Claude: ${ev.text}`); + else if (ev.type === 'tool') appendTool(ev.tool, ev.input); + else if (ev.type === 'result') appendResult(ev.tool, ev.result); + else if (ev.type === 'error') appendError(ev.error); + } + } catch (err) { + appendError(err.message || String(err)); + } finally { + formEl.querySelector('button').disabled = false; + inputEl.focus(); + } + }); + + loginBtn.addEventListener('click', () => loginOrSignup('login')); + signupBtn.addEventListener('click', () => loginOrSignup('signup')); + logoutBtn.addEventListener('click', () => { setAuthToken(''); userEmailEl.textContent = ''; appendMessage('assistant', 'Logged out.'); }); + subscribeBtn.addEventListener('click', startCheckout); + portalBtn.addEventListener('click', openPortal); + + // Kick off session and auth state on load + ensureSession().catch((e) => appendError(e.message || String(e))); + fetchMe().then((me) => { + if (me.email) userEmailEl.textContent = me.email; + }); + refreshAuthUI(); +})(); + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..be4b0af --- /dev/null +++ b/web/index.html @@ -0,0 +1,32 @@ + + + + + + Coding Agent UI + + + +
+

Coding Agent

+
Disconnected
+
+ + + + + + +
+
+
+
+
+ + +
+
+ + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..ad4bf58 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,91 @@ +:root { + --bg: #0b1220; + --panel: #121a2a; + --text: #e7ecf3; + --muted: #9fb3c8; + --accent: #6ea8fe; + --tool: #1f2a44; + --error: #ff6b6b; + --result: #144b2d; +} + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + height: 100%; +} + +header { + background: var(--panel); + padding: 12px 16px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid #1e2940; +} + +h1 { + margin: 0; + font-size: 18px; +} + +.status { color: var(--muted); font-size: 12px; } + +.auth-controls { display: flex; align-items: center; gap: 8px; } +.auth-controls button { font-size: 12px; padding: 6px 10px; } +.auth-controls #user-email { color: var(--muted); font-size: 12px; margin-right: 6px; } + +main { height: calc(100vh - 58px); display: flex; flex-direction: column; } + +.messages { + flex: 1; + overflow-y: auto; + padding: 16px; + line-height: 1.5; +} + +.msg { margin: 8px 0; white-space: pre-wrap; } +.msg.user { color: var(--text); } +.msg.assistant { color: var(--muted); } +.msg.tool { background: var(--tool); border-radius: 8px; padding: 8px; } +.msg.result { background: var(--result); border-radius: 8px; padding: 8px; } +.msg.error { color: var(--error); } + +.composer { + display: flex; + gap: 8px; + padding: 12px; + background: var(--panel); + border-top: 1px solid #1e2940; +} + +input[type="text"] { + flex: 1; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #22314f; + background: #0d1728; + color: var(--text); + outline: none; +} + +button { + padding: 10px 14px; + border-radius: 8px; + background: var(--accent); + color: #041830; + border: none; + cursor: pointer; + font-weight: 600; +} + +button:disabled { opacity: 0.6; cursor: not-allowed; } + +pre { margin: 0; white-space: pre-wrap; word-break: break-word; } +