Initial MVP implementation of Claudia Docs CLI
- Auth commands: login, logout, status - Document commands: create, list, get, update, delete - Project commands: list, get - Folder commands: list, create - Tag commands: list, create, add - Search command - Reasoning save command - JSON output format with --output text option - Viper-based configuration management - Cobra CLI framework
This commit is contained in:
110
internal/api/client.go
Normal file
110
internal/api/client.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/claudia/docs-cli/internal/config"
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// Client is the API client
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient() (*Client, error) {
|
||||
cfg := config.GetConfig()
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{Server: "http://localhost:8000", Timeout: "30s"}
|
||||
}
|
||||
|
||||
timeout, err := time.ParseDuration(cfg.Timeout)
|
||||
if err != nil {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
return &Client{
|
||||
BaseURL: cfg.Server + "/api/v1",
|
||||
Token: cfg.Token,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
Timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetToken sets the authentication token
|
||||
func (c *Client) SetToken(token string) {
|
||||
c.Token = token
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request
|
||||
func (c *Client) doRequest(method, path string, body interface{}) (*http.Response, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
url := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
|
||||
return c.HTTPClient.Do(req)
|
||||
}
|
||||
|
||||
// DoRequest performs an HTTP request (exported for auth package)
|
||||
func (c *Client) DoRequest(method, path string, body interface{}) (*http.Response, error) {
|
||||
return c.doRequest(method, path, body)
|
||||
}
|
||||
|
||||
// Response parses the API response
|
||||
type Response struct {
|
||||
Success bool
|
||||
Data interface{}
|
||||
Error *types.ErrorInfo
|
||||
}
|
||||
|
||||
func parseResponse(resp *http.Response) (*types.Response, error) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var apiResp types.Response
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response JSON: %w", err)
|
||||
}
|
||||
|
||||
return &apiResp, nil
|
||||
}
|
||||
|
||||
func handleError(resp *http.Response, body []byte) error {
|
||||
var apiResp types.Response
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if apiResp.Error != nil {
|
||||
return fmt.Errorf("%s: %s", apiResp.Error.Code, apiResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
216
internal/api/documents.go
Normal file
216
internal/api/documents.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// Documents returns the documents client
|
||||
type DocumentsClient struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewDocumentsClient creates a new documents client
|
||||
func NewDocumentsClient(c *Client) *DocumentsClient {
|
||||
return &DocumentsClient{client: c}
|
||||
}
|
||||
|
||||
// Create creates a new document
|
||||
func (d *DocumentsClient) Create(projectID string, req types.CreateDocumentRequest) (*types.Document, error) {
|
||||
resp, err := d.client.doRequest(http.MethodPost, "/projects/"+projectID+"/documents", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var docResp types.Response
|
||||
if err := decodeJSON(body, &docResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc, ok := docResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToDocument(doc), nil
|
||||
}
|
||||
|
||||
// List lists documents
|
||||
func (d *DocumentsClient) List(projectID, folderID string, limit, offset int) ([]types.Document, error) {
|
||||
path := "/projects/" + projectID + "/documents"
|
||||
query := fmt.Sprintf("?limit=%d&offset=%d", limit, offset)
|
||||
if folderID != "" {
|
||||
query += "&folder_id=" + folderID
|
||||
}
|
||||
|
||||
resp, err := d.client.doRequest(http.MethodGet, path+query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var docResp types.Response
|
||||
if err := decodeJSON(body, &docResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sliceToDocuments(docResp.Data), nil
|
||||
}
|
||||
|
||||
// Get gets a document by ID
|
||||
func (d *DocumentsClient) Get(id string, includeReasoning, includeTags bool) (*types.Document, error) {
|
||||
path := "/documents/" + id
|
||||
query := ""
|
||||
if includeReasoning || includeTags {
|
||||
query = "?"
|
||||
if includeReasoning {
|
||||
query += "include_reasoning=true"
|
||||
}
|
||||
if includeTags {
|
||||
if includeReasoning {
|
||||
query += "&"
|
||||
}
|
||||
query += "include_tags=true"
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := d.client.doRequest(http.MethodGet, path+query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var docResp types.Response
|
||||
if err := decodeJSON(body, &docResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc, ok := docResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToDocument(doc), nil
|
||||
}
|
||||
|
||||
// Update updates a document
|
||||
func (d *DocumentsClient) Update(id string, req types.UpdateDocumentRequest) (*types.Document, error) {
|
||||
resp, err := d.client.doRequest(http.MethodPut, "/documents/"+id, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var docResp types.Response
|
||||
if err := decodeJSON(body, &docResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc, ok := docResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToDocument(doc), nil
|
||||
}
|
||||
|
||||
// Delete deletes a document
|
||||
func (d *DocumentsClient) Delete(id string) error {
|
||||
resp, err := d.client.doRequest(http.MethodDelete, "/documents/"+id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return handleError(resp, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func decodeJSON(data []byte, v interface{}) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func mapToDocument(m map[string]interface{}) *types.Document {
|
||||
doc := &types.Document{}
|
||||
if v, ok := m["id"].(string); ok {
|
||||
doc.ID = v
|
||||
}
|
||||
if v, ok := m["title"].(string); ok {
|
||||
doc.Title = v
|
||||
}
|
||||
if v, ok := m["content"].(string); ok {
|
||||
doc.Content = v
|
||||
}
|
||||
if v, ok := m["project_id"].(string); ok {
|
||||
doc.ProjectID = v
|
||||
}
|
||||
if v, ok := m["folder_id"].(string); ok {
|
||||
doc.FolderID = v
|
||||
}
|
||||
if v, ok := m["created_at"].(string); ok {
|
||||
doc.CreatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
if v, ok := m["updated_at"].(string); ok {
|
||||
doc.UpdatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func sliceToDocuments(data interface{}) []types.Document {
|
||||
var docs []types.Document
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
docs = append(docs, *mapToDocument(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
145
internal/api/folders.go
Normal file
145
internal/api/folders.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// FoldersClient handles folder API calls
|
||||
type FoldersClient struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewFoldersClient creates a new folders client
|
||||
func NewFoldersClient(c *Client) *FoldersClient {
|
||||
return &FoldersClient{client: c}
|
||||
}
|
||||
|
||||
// List lists folders in a project
|
||||
func (f *FoldersClient) List(projectID, parentID string) ([]types.Folder, error) {
|
||||
path := "/projects/" + projectID + "/folders"
|
||||
if parentID != "" {
|
||||
path += "?parent_id=" + parentID
|
||||
}
|
||||
|
||||
resp, err := f.client.doRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var folderResp types.Response
|
||||
if err := decodeJSON(body, &folderResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sliceToFolders(folderResp.Data), nil
|
||||
}
|
||||
|
||||
// Create creates a new folder
|
||||
func (f *FoldersClient) Create(projectID string, req types.CreateFolderRequest) (*types.Folder, error) {
|
||||
resp, err := f.client.doRequest(http.MethodPost, "/projects/"+projectID+"/folders", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var folderResp types.Response
|
||||
if err := decodeJSON(body, &folderResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folder, ok := folderResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToFolder(folder), nil
|
||||
}
|
||||
|
||||
// Get gets a folder by ID
|
||||
func (f *FoldersClient) Get(id string) (*types.Folder, error) {
|
||||
resp, err := f.client.doRequest(http.MethodGet, "/folders/"+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var folderResp types.Response
|
||||
if err := decodeJSON(body, &folderResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folder, ok := folderResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToFolder(folder), nil
|
||||
}
|
||||
|
||||
func mapToFolder(m map[string]interface{}) *types.Folder {
|
||||
folder := &types.Folder{}
|
||||
if v, ok := m["id"].(string); ok {
|
||||
folder.ID = v
|
||||
}
|
||||
if v, ok := m["name"].(string); ok {
|
||||
folder.Name = v
|
||||
}
|
||||
if v, ok := m["project_id"].(string); ok {
|
||||
folder.ProjectID = v
|
||||
}
|
||||
if v, ok := m["parent_id"].(string); ok {
|
||||
folder.ParentID = v
|
||||
}
|
||||
if v, ok := m["created_at"].(string); ok {
|
||||
folder.CreatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
if v, ok := m["updated_at"].(string); ok {
|
||||
folder.UpdatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
func sliceToFolders(data interface{}) []types.Folder {
|
||||
var folders []types.Folder
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
folders = append(folders, *mapToFolder(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
return folders
|
||||
}
|
||||
137
internal/api/projects.go
Normal file
137
internal/api/projects.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// ProjectsClient handles project API calls
|
||||
type ProjectsClient struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewProjectsClient creates a new projects client
|
||||
func NewProjectsClient(c *Client) *ProjectsClient {
|
||||
return &ProjectsClient{client: c}
|
||||
}
|
||||
|
||||
// List lists all projects
|
||||
func (p *ProjectsClient) List() ([]types.Project, error) {
|
||||
resp, err := p.client.doRequest(http.MethodGet, "/projects", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var projResp types.Response
|
||||
if err := decodeJSON(body, &projResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sliceToProjects(projResp.Data), nil
|
||||
}
|
||||
|
||||
// Get gets a project by ID
|
||||
func (p *ProjectsClient) Get(id string) (*types.Project, error) {
|
||||
resp, err := p.client.doRequest(http.MethodGet, "/projects/"+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var projResp types.Response
|
||||
if err := decodeJSON(body, &projResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proj, ok := projResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToProject(proj), nil
|
||||
}
|
||||
|
||||
// Create creates a new project
|
||||
func (p *ProjectsClient) Create(req types.CreateProjectRequest) (*types.Project, error) {
|
||||
resp, err := p.client.doRequest(http.MethodPost, "/projects", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var projResp types.Response
|
||||
if err := decodeJSON(body, &projResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proj, ok := projResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToProject(proj), nil
|
||||
}
|
||||
|
||||
func mapToProject(m map[string]interface{}) *types.Project {
|
||||
proj := &types.Project{}
|
||||
if v, ok := m["id"].(string); ok {
|
||||
proj.ID = v
|
||||
}
|
||||
if v, ok := m["name"].(string); ok {
|
||||
proj.Name = v
|
||||
}
|
||||
if v, ok := m["description"].(string); ok {
|
||||
proj.Description = v
|
||||
}
|
||||
if v, ok := m["created_at"].(string); ok {
|
||||
proj.CreatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
if v, ok := m["updated_at"].(string); ok {
|
||||
proj.UpdatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
return proj
|
||||
}
|
||||
|
||||
func sliceToProjects(data interface{}) []types.Project {
|
||||
var projects []types.Project
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
projects = append(projects, *mapToProject(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
return projects
|
||||
}
|
||||
122
internal/api/reasoning.go
Normal file
122
internal/api/reasoning.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// ReasoningClient handles reasoning API calls
|
||||
type ReasoningClient struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewReasoningClient creates a new reasoning client
|
||||
func NewReasoningClient(c *Client) *ReasoningClient {
|
||||
return &ReasoningClient{client: c}
|
||||
}
|
||||
|
||||
// Save saves reasoning for a document
|
||||
func (r *ReasoningClient) Save(docID string, req types.SaveReasoningRequest) (*types.Reasoning, error) {
|
||||
path := "/documents/" + docID + "/reasoning"
|
||||
resp, err := r.client.doRequest(http.MethodPut, path, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var reasoningResp types.Response
|
||||
if err := decodeJSON(body, &reasoningResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reasoning, ok := reasoningResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToReasoning(reasoning), nil
|
||||
}
|
||||
|
||||
// Get gets reasoning for a document
|
||||
func (r *ReasoningClient) Get(docID string) (*types.Reasoning, error) {
|
||||
path := "/documents/" + docID + "/reasoning"
|
||||
resp, err := r.client.doRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var reasoningResp types.Response
|
||||
if err := decodeJSON(body, &reasoningResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reasoning, ok := reasoningResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToReasoning(reasoning), nil
|
||||
}
|
||||
|
||||
func mapToReasoning(m map[string]interface{}) *types.Reasoning {
|
||||
reasoning := &types.Reasoning{}
|
||||
if v, ok := m["doc_id"].(string); ok {
|
||||
reasoning.DocID = v
|
||||
}
|
||||
if v, ok := m["type"].(string); ok {
|
||||
reasoning.Type = v
|
||||
}
|
||||
if v, ok := m["confidence"].(float64); ok {
|
||||
reasoning.Confidence = v
|
||||
}
|
||||
if v, ok := m["model"].(string); ok {
|
||||
reasoning.Model = v
|
||||
}
|
||||
if v, ok := m["created_at"].(string); ok {
|
||||
reasoning.CreatedAt, _ = time.Parse(time.RFC3339, v)
|
||||
}
|
||||
if steps, ok := m["steps"].([]interface{}); ok {
|
||||
for _, step := range steps {
|
||||
if s, ok := step.(map[string]interface{}); ok {
|
||||
reasoningStep := types.ReasoningStep{}
|
||||
if sid, ok := s["step_id"].(string); ok {
|
||||
reasoningStep.StepID = sid
|
||||
}
|
||||
if thought, ok := s["thought"].(string); ok {
|
||||
reasoningStep.Thought = thought
|
||||
}
|
||||
if conclusion, ok := s["conclusion"].(string); ok {
|
||||
reasoningStep.Conclusion = conclusion
|
||||
}
|
||||
if action, ok := s["action"].(string); ok {
|
||||
reasoningStep.Action = action
|
||||
}
|
||||
reasoning.Steps = append(reasoning.Steps, reasoningStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasoning
|
||||
}
|
||||
72
internal/api/search.go
Normal file
72
internal/api/search.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// SearchClient handles search API calls
|
||||
type SearchClient struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewSearchClient creates a new search client
|
||||
func NewSearchClient(c *Client) *SearchClient {
|
||||
return &SearchClient{client: c}
|
||||
}
|
||||
|
||||
// Search performs a full-text search
|
||||
func (s *SearchClient) Search(query, projectID, tags string) (*types.SearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
if projectID != "" {
|
||||
params.Set("project_id", projectID)
|
||||
}
|
||||
if tags != "" {
|
||||
params.Set("tags", tags)
|
||||
}
|
||||
|
||||
path := "/search?" + params.Encode()
|
||||
resp, err := s.client.doRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var searchResp types.Response
|
||||
if err := decodeJSON(body, &searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapToSearchResult(searchResp.Data, query), nil
|
||||
}
|
||||
|
||||
func mapToSearchResult(data interface{}, query string) *types.SearchResult {
|
||||
result := &types.SearchResult{Query: query}
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if v, ok := m["documents"].([]interface{}); ok {
|
||||
for _, item := range v {
|
||||
if doc, ok := item.(map[string]interface{}); ok {
|
||||
result.Documents = append(result.Documents, *mapToDocument(doc))
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := m["total"].(float64); ok {
|
||||
result.Total = int(v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
132
internal/api/tags.go
Normal file
132
internal/api/tags.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// TagsClient handles tag API calls
|
||||
type TagsClient struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewTagsClient creates a new tags client
|
||||
func NewTagsClient(c *Client) *TagsClient {
|
||||
return &TagsClient{client: c}
|
||||
}
|
||||
|
||||
// List lists all tags
|
||||
func (t *TagsClient) List() ([]types.Tag, error) {
|
||||
resp, err := t.client.doRequest(http.MethodGet, "/tags", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var tagResp types.Response
|
||||
if err := decodeJSON(body, &tagResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sliceToTags(tagResp.Data), nil
|
||||
}
|
||||
|
||||
// Create creates a new tag
|
||||
func (t *TagsClient) Create(req types.CreateTagRequest) (*types.Tag, error) {
|
||||
resp, err := t.client.doRequest(http.MethodPost, "/tags", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, handleError(resp, body)
|
||||
}
|
||||
|
||||
var tagResp types.Response
|
||||
if err := decodeJSON(body, &tagResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tag, ok := tagResp.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
return mapToTag(tag), nil
|
||||
}
|
||||
|
||||
// AddToDocument adds a tag to a document
|
||||
func (t *TagsClient) AddToDocument(docID, tagID string) error {
|
||||
resp, err := t.client.doRequest(http.MethodPost, "/documents/"+docID+"/tags/"+tagID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return handleError(resp, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFromDocument removes a tag from a document
|
||||
func (t *TagsClient) RemoveFromDocument(docID, tagID string) error {
|
||||
resp, err := t.client.doRequest(http.MethodDelete, "/documents/"+docID+"/tags/"+tagID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return handleError(resp, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapToTag(m map[string]interface{}) *types.Tag {
|
||||
tag := &types.Tag{}
|
||||
if v, ok := m["id"].(string); ok {
|
||||
tag.ID = v
|
||||
}
|
||||
if v, ok := m["name"].(string); ok {
|
||||
tag.Name = v
|
||||
}
|
||||
if v, ok := m["color"].(string); ok {
|
||||
tag.Color = v
|
||||
}
|
||||
return tag
|
||||
}
|
||||
|
||||
func sliceToTags(data interface{}) []types.Tag {
|
||||
var tags []types.Tag
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
tags = append(tags, *mapToTag(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
169
internal/auth/auth.go
Normal file
169
internal/auth/auth.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/claudia/docs-cli/internal/api"
|
||||
"github.com/claudia/docs-cli/internal/config"
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// AuthClient handles authentication
|
||||
type AuthClient struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewAuthClient creates a new auth client
|
||||
func NewAuthClient(c *api.Client) *AuthClient {
|
||||
return &AuthClient{client: c}
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns the token
|
||||
func (a *AuthClient) Login(username, password string) (*types.AuthResponse, error) {
|
||||
req := types.LoginRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := a.client.DoRequest(http.MethodPost, "/auth/login", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, handleAuthError(resp, body)
|
||||
}
|
||||
|
||||
var authResp types.Response
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapToAuthResponse(authResp.Data), nil
|
||||
}
|
||||
|
||||
// Register registers a new user
|
||||
func (a *AuthClient) Register(username, password string) (*types.AuthResponse, error) {
|
||||
req := types.RegisterRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
resp, err := a.client.DoRequest(http.MethodPost, "/auth/register", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
return nil, handleAuthError(resp, body)
|
||||
}
|
||||
|
||||
var authResp types.Response
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mapToAuthResponse(authResp.Data), nil
|
||||
}
|
||||
|
||||
// Me returns the current user info
|
||||
func (a *AuthClient) Me() (map[string]interface{}, error) {
|
||||
resp, err := a.client.DoRequest(http.MethodGet, "/auth/me", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, handleAuthError(resp, body)
|
||||
}
|
||||
|
||||
var authResp types.Response
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m, ok := authResp.Data.(map[string]interface{}); ok {
|
||||
return m, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
// Logout clears the saved token
|
||||
func Logout() error {
|
||||
return config.ClearToken()
|
||||
}
|
||||
|
||||
// Status returns the current auth status
|
||||
func Status() (map[string]interface{}, error) {
|
||||
cfg := config.GetConfig()
|
||||
if cfg == nil {
|
||||
return map[string]interface{}{
|
||||
"authenticated": false,
|
||||
"server": "http://localhost:8000",
|
||||
}, nil
|
||||
}
|
||||
|
||||
token := cfg.Token
|
||||
if token == "" {
|
||||
return map[string]interface{}{
|
||||
"authenticated": false,
|
||||
"server": cfg.Server,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"authenticated": true,
|
||||
"server": cfg.Server,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func handleAuthError(resp *http.Response, body []byte) error {
|
||||
var authResp types.Response
|
||||
if err := json.Unmarshal(body, &authResp); err != nil {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if authResp.Error != nil {
|
||||
return fmt.Errorf("%s: %s", authResp.Error.Code, authResp.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func mapToAuthResponse(data interface{}) *types.AuthResponse {
|
||||
resp := &types.AuthResponse{}
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if v, ok := m["access_token"].(string); ok {
|
||||
resp.Token = v
|
||||
}
|
||||
if v, ok := m["token_type"].(string); ok {
|
||||
resp.TokenType = v
|
||||
}
|
||||
if v, ok := m["expires_at"].(string); ok {
|
||||
resp.ExpiresAt, _ = time.Parse(time.RFC3339, v)
|
||||
} else if v, ok := m["expires_at"].(float64); ok {
|
||||
resp.ExpiresAt = time.Unix(int64(v), 0)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
124
internal/config/config.go
Normal file
124
internal/config/config.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config holds all configuration
|
||||
type Config struct {
|
||||
Server string
|
||||
Token string
|
||||
Output string
|
||||
Timeout string
|
||||
Quiet bool
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
|
||||
// Load initializes the configuration
|
||||
func Load() (*Config, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
viper.SetConfigName(".claudia-docs")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(home)
|
||||
|
||||
// Set defaults
|
||||
viper.SetDefault("server", "http://localhost:8000")
|
||||
viper.SetDefault("output", "json")
|
||||
viper.SetDefault("timeout", "30s")
|
||||
|
||||
// Environment variables
|
||||
viper.SetEnvPrefix("CLAUDIA")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// Read config file if exists
|
||||
_ = viper.ReadInConfig()
|
||||
|
||||
cfg = &Config{
|
||||
Server: viper.GetString("server"),
|
||||
Token: viper.GetString("token"),
|
||||
Output: viper.GetString("output"),
|
||||
Timeout: viper.GetString("timeout"),
|
||||
Quiet: viper.GetBool("quiet"),
|
||||
Verbose: viper.GetBool("verbose"),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetToken returns the current token
|
||||
func GetToken() string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.Token
|
||||
}
|
||||
|
||||
// GetServer returns the current server URL
|
||||
func GetServer() string {
|
||||
if cfg == nil {
|
||||
return "http://localhost:8000"
|
||||
}
|
||||
return cfg.Server
|
||||
}
|
||||
|
||||
// SaveToken saves a token to the config file
|
||||
func SaveToken(token string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".claudia-docs.yaml")
|
||||
viper.SetConfigFile(configPath)
|
||||
|
||||
viper.Set("token", token)
|
||||
|
||||
return viper.WriteConfig()
|
||||
}
|
||||
|
||||
// ClearToken removes the token from config
|
||||
func ClearToken() error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".claudia-docs.yaml")
|
||||
viper.SetConfigFile(configPath)
|
||||
|
||||
viper.Set("token", "")
|
||||
|
||||
return viper.WriteConfig()
|
||||
}
|
||||
|
||||
// GetConfig returns the current config
|
||||
func GetConfig() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// UpdateFromFlags updates config from CLI flags
|
||||
func UpdateFromFlags(server, token, output string, quiet, verbose bool) {
|
||||
if server != "" {
|
||||
cfg.Server = server
|
||||
}
|
||||
if token != "" {
|
||||
cfg.Token = token
|
||||
}
|
||||
if output != "" {
|
||||
cfg.Output = output
|
||||
}
|
||||
cfg.Quiet = quiet
|
||||
cfg.Verbose = verbose
|
||||
}
|
||||
82
internal/output/output.go
Normal file
82
internal/output/output.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/claudia/docs-cli/pkg/types"
|
||||
)
|
||||
|
||||
// Formatter handles output formatting
|
||||
type Formatter struct {
|
||||
Command string
|
||||
Server string
|
||||
}
|
||||
|
||||
// NewFormatter creates a new formatter
|
||||
func NewFormatter(command, server string) *Formatter {
|
||||
return &Formatter{
|
||||
Command: command,
|
||||
Server: server,
|
||||
}
|
||||
}
|
||||
|
||||
// Success returns a success response
|
||||
func (f *Formatter) Success(data interface{}, durationMs int64) types.Response {
|
||||
return types.Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Meta: types.MetaInfo{
|
||||
Command: f.Command,
|
||||
DurationMs: durationMs,
|
||||
Server: f.Server,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Error returns an error response
|
||||
func (f *Formatter) Error(code, message string, durationMs int64) types.Response {
|
||||
return types.Response{
|
||||
Success: false,
|
||||
Error: &types.ErrorInfo{
|
||||
Code: code,
|
||||
Message: message,
|
||||
},
|
||||
Meta: types.MetaInfo{
|
||||
Command: f.Command,
|
||||
DurationMs: durationMs,
|
||||
Server: f.Server,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PrintJSON prints the response as JSON
|
||||
func PrintJSON(resp types.Response) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(resp)
|
||||
}
|
||||
|
||||
// PrintJSONCompact prints the response as compact JSON
|
||||
func PrintJSONCompact(resp types.Response) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
_ = enc.Encode(resp)
|
||||
}
|
||||
|
||||
// PrintText prints a human-readable message
|
||||
func PrintText(message string) {
|
||||
fmt.Println(message)
|
||||
}
|
||||
|
||||
// PrintError prints a human-readable error
|
||||
func PrintError(message string) {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s\n", message)
|
||||
}
|
||||
|
||||
// MeasureDuration returns a function to measure duration
|
||||
func MeasureDuration() (int64, func()) {
|
||||
return int64(0), func() {
|
||||
// Duration is calculated where needed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user