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:
Claudia CLI Bot
2026-03-31 01:25:15 +00:00
commit aca95d90f3
17 changed files with 3270 additions and 0 deletions

110
internal/api/client.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}