commit aca95d90f30e4b9679b865e7a9d95c381bae26fa Author: Claudia CLI Bot Date: Tue Mar 31 01:25:15 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6795295 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Binaries +claudia-docs +*.exe +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output +*.out + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Vendor (if using) +/vendor/ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..8217206 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,371 @@ +# Claudia Docs CLI — Handoff para Coder + +## Overview + +Este documento es la guía de inicio para implementar **Claudia Docs CLI**, una herramienta de línea de comandos en Go que permite a Claudia (y otros agentes) interactuar con Claudia Docs. + +**Tiempo estimado de implementación:** 2-3 días (MVP completo) + +--- + +## 1. Contexto del Proyecto + +### Qué es +- CLI written en **Go** con framework **Cobra** +- Se comunica con la API REST de Claudia Docs (`/api/v1`) +- Autenticación via JWT +- Output por defecto en JSON, parseable por agentes + +### Para qué sirve +- Claudia guarda documentos y reasoning via CLI +- Agentes pueden integrar el CLI en sus scripts/prompts +- No requiere navegador ni UI web + +### Repositorio +``` +/projects/claudia-docs-cli/ +├── SPEC.md # Especificación completa (LEER PRIMERO) +├── HANDOFF.md # Este documento +├── cmd/ # Entry point +├── internal/ # Lógica de negocio +└── pkg/types/ # Tipos compartidos +``` + +--- + +## 2. Documentación Obligatoria + +**LEER ANTES DE CODear:** + +1. **`SPEC.md`** — Especificación completa del CLI + - Command structure + - Flags y argumentos + - Output format + - Configuración + - Estructura del proyecto + +2. **`../claudia-docs/SPEC.md`** — Spec del backend + - API endpoints (sección 5) + - Data model (sección 4) + - Auth flow + - Para probar la API mientras sviluppo + +--- + +## 3. Tech Stack + +| Componente | Tecnología | +|------------|------------| +| Language | Go 1.21+ | +| CLI Framework | `github.com/spf13/cobra` | +| Config | `github.com/spf13/viper` | +| JWT | `github.com/golang-jwt/jwt/v5` | +| HTTP | `net/http` (stdlib) | +| JSON | `encoding/json` (stdlib) | + +**No agregar otras dependencias sin consultar.** + +--- + +## 4. Configuración del Entorno + +### Backend local (para testing) +```bash +# En /projects/claudia-docs/ +docker-compose up -d +# API disponible en http://localhost:8000 +# Swagger: http://localhost:8000/docs +``` + +### Credenciales de test +```bash +INITIAL_ADMIN_USERNAME=admin +INITIAL_ADMIN_PASSWORD=admin123 +``` + +### Variables de entorno para testing +```bash +export CLAUDIA_SERVER=http://localhost:8000 +export CLAUDIA_TOKEN="" +``` + +--- + +## 5. Implementación Paso a Paso + +### Paso 1: Setup del proyecto + +```bash +cd /projects/claudia-docs-cli + +# Inicializar go module +go mod init github.com/claudia/docs-cli + +# Agregar dependencias +go get github.com/spf13/cobra@v1.8.0 +go get github.com/spf13/viper@v1.18.2 +go get github.com/golang-jwt/jwt/v5@v5.2.0 +``` + +### Paso 2: Estructura de carpetas + +``` +cmd/claudia-docs/main.go # Entry point +internal/ + api/client.go # HTTP client base + api/documents.go # Document endpoints + auth/auth.go # Login, JWT handling + config/config.go # Viper config + output/output.go # JSON/Text formatting +pkg/types/types.go # Tipos compartidos +cobra/commands.go # Definitions de comandos +``` + +### Paso 3: Implementar en orden + +1. **`pkg/types/types.go`** — Tipos básicos (Request/Response) +2. **`internal/config/config.go`** — Carga de config desde yaml/env +3. **`internal/output/output.go`** — Formatter JSON/Text +4. **`internal/api/client.go`** — HTTP client con auth +5. **`internal/auth/auth.go`** — Login, logout, JWT +6. **`cobra/commands.go`** — Estructura de comandos +7. **`cmd/claudia-docs/main.go`** — Entry point + root command +8. **`internal/api/*.go`** — Cada recurso (documents, projects, folders, tags, search, reasoning) + +### Paso 4: Testing manual + +```bash +# Build +go build -o claudia-docs ./cmd/claudia-docs + +# Login +./claudia-docs auth login -u admin -p admin123 + +# Probar commands +./claudia-docs auth status +./claudia-docs project list +./claudia-docs doc create -t "Test" -c "# Hello" -p +./claudia-docs doc list --project-id +``` + +--- + +## 6. API Endpoints a Consumir + +Base URL: `${CLAUDIA_SERVER}/api/v1` + +### Auth +``` +POST /auth/register +POST /auth/login → { access_token } +GET /auth/me +``` + +### Projects +``` +GET /projects +POST /projects +GET /projects/:id +PUT /projects/:id +DELETE /projects/:id +``` + +### Documents +``` +GET /projects/:projectId/documents +POST /projects/:projectId/documents +GET /documents/:id +PUT /documents/:id +DELETE /documents/:id +PUT /documents/:id/content +``` + +### Folders +``` +GET /projects/:projectId/folders +POST /projects/:projectId/folders +GET /folders/:id +PUT /folders/:id +DELETE /folders/:id +``` + +### Tags +``` +GET /tags +POST /tags +POST /documents/:id/tags +DELETE /documents/:id/tags/:tagId +``` + +### Search +``` +GET /search?q=...&project_id=...&tags=... +``` + +### Reasoning +``` +PUT /documents/:id/reasoning +GET /documents/:id/reasoning +``` + +**Ver spec del backend para Request/Response bodies exactos.** + +--- + +## 7. Formato de Output + +### JSON Response Structure + +```go +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` + Meta MetaInfo `json:"meta"` +} + +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type MetaInfo struct { + Command string `json:"command"` + DurationMs int64 `json:"duration_ms"` + Server string `json:"server,omitempty"` +} +``` + +### Comportamiento + +- **Success:** `{"success": true, "data": {...}, "meta": {...}}` +- **Error:** `{"success": false, "error": {"code": "...", "message": "..."}, "meta": {...}}` +- **--quiet:** Solo JSON, sin prints a stdout +- **--output text:** Print amigable para humanos + +--- + +## 8. Config File + +Path: `~/.claudia-docs.yaml` + +```yaml +server: http://localhost:8000 +token: "" +timeout: 30s +output: json + +agents: + default: + token: "" + default_project: "" +``` + +**Viper maneja优先级:** +1. Flags (--server, --token) +2. Environment (CLAUDIA_SERVER, CLAUDIA_TOKEN) +3. Config file (~/.claudia-docs.yaml) +4. Defaults + +--- + +## 9. Testing Checklist + +### Auth +- [ ] Login exitoso guarda token en config +- [ ] Login con credenciales inválidas retorna error +- [ ] Logout limpia token +- [ ] Commands sin token retornan 401 + +### Documents +- [ ] Create documento retorna id +- [ ] List retorna array de documentos +- [ ] Get retorna documento con contenido +- [ ] Update cambia título/contenido +- [ ] Delete elimina documento + +### Projects/Folders +- [ ] CRUD completo funciona + +### Search +- [ ] Búsqueda retorna resultados +- [ ] Filtros por proyecto/tag funcionan + +### Output +- [ ] JSON output es válido +- [ ] --output text muestra formato legible +- [ ] --quiet solo imprime JSON + +--- + +## 10. Build & Release + +### Build local +```bash +# Linux +GOOS=linux GOARCH=amd64 go build -o claudia-docs-linux-amd64 ./cmd/claudia-docs + +# macOS +GOOS=darwin GOARCH=amd64 go build -o claudia-docs-darwin-amd64 ./cmd/claudia-docs + +# Windows +GOOS=windows GOARCH=amd64 go build -o claudia-docs-windows-amd64.exe ./cmd/claudia-docs +``` + +### Release script (para después) +```bash +# Generar checksums +sha256sum claudia-docs-* > checksums.txt + +# Tag y release en Git +git tag v1.0.0 +git push origin v1.0.0 +``` + +--- + +## 11. Criterios de Aceptación + +### Must Have (MVP) +- [ ] `claudia-docs auth login` funciona y guarda token +- [ ] `claudia-docs auth logout` limpia token +- [ ] `claudia-docs auth status` muestra estado +- [ ] `claudia-docs doc create` crea documento +- [ ] `claudia-docs doc list` lista documentos +- [ ] `claudia-docs doc get` obtiene documento +- [ ] `claudia-docs doc update` actualiza documento +- [ ] `claudia-docs doc delete` elimina documento +- [ ] `claudia-docs project list` lista proyectos +- [ ] Output JSON es parseable +- [ ] Config file ~/.claudia-docs.yaml se crea +- [ ] Flags --server y --token funcionan +- [ ] Help con --help funciona + +### Should Have (Phase 2) +- [ ] Tags CRUD +- [ ] Search +- [ ] Reasoning save +- [ ] --output text + +--- + +## 12. Contacto para Dudas + +**Para preguntas sobre el diseño o specs:** +- Crear issue en el proyecto +- O preguntar directamente + +**Para preguntas sobre implementación:** +- Si hay ambigüedad en el spec, preguntar antes de asumir + +--- + +## 13. Enlaces + +- Spec CLI: `/projects/claudia-docs-cli/SPEC.md` +- Spec Backend: `/projects/claudia-docs/SPEC.md` +- Backend API Docs: `http://localhost:8000/docs` (si backend corriendo) + +--- + +**¡Happy coding! 🚀** diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..4bdc295 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,564 @@ +# Claudia Docs CLI — Project Specification + +## 1. Concept & Vision + +**Claudia Docs CLI** es una herramienta de línea de comandos que permite a Claudia (y otros agentes) interactuar con Claudia Docs sin necesidad de un navegador. Diseñado para ser invocado desde prompts, scripts y pipelines de automatización. + +**Propósito:** Ser la interfaz preferida para agentes que necesitan crear documentos, guardar reasoning, y consultar información programáticamente. + +**Differential:** No es un CLI genérico de API. Está optimizado para el flujo de trabajo de un agente de investigación. + +--- + +## 2. Tech Stack + +| Componente | Tecnología | Pourquoi | +|------------|------------|----------| +| **Language** | Go 1.21+ | Startup rápido, single binary, cross-compile | +| **CLI Framework** | Cobra | Battle-tested (kubectl, Hugo), experiencia del equipo | +| **HTTP Client** | net/http (stdlib) | Sin dependencias extra | +| **JWT** | golang-jwt/jwt/v5 | Maduro, mantenimiento activo | +| **JSON** | encoding/json (stdlib) | Rápido, sin dependencias | +| **Config** | spf13/viper | Manejo de config de forma idiomática | + +--- + +## 3. Installation + +### Binary Download (Recomendado para agentes) + +```bash +# Linux/macOS +curl -fsSL https://releases.claudia-docs.io/latest/install.sh | bash + +# Directo +curl -fsSL https://releases.claudia-docs.io/claudia-docs-linux-amd64 -o /usr/local/bin/claudia-docs +chmod +x /usr/local/bin/claudia-docs +``` + +### Build from Source + +```bash +git clone https://git.example.com/claudia-docs-cli.git +cd claudia-docs-cli +go build -o claudia-docs ./cmd/claudia-docs +./claudia-docs --version +``` + +### npm (desarrolladores) + +```bash +npm install -g @claudia/docs-cli +``` + +--- + +## 4. Command Structure + +``` +claudia-docs [global options] [arguments] + +GLOBAL OPTIONS: + --server URL API server URL (default: http://localhost:8000) + --token JWT JWT token (alternativa: CLAUDIA_TOKEN env var) + --output format Output format: json, text (default: json) + --quiet Suppress stdout, solo JSON + --config path Path to config file (default: ~/.claudia-docs.yaml) + --verbose Verbose output (debug) + --version Show version + --help Show help +``` + +--- + +### 4.1 Auth Commands + +#### `claudia-docs auth login` +Autentica y guarda el token. + +```bash +claudia-docs auth login -u -p + +# Output JSON: +{ + "success": true, + "data": { + "token": "eyJhbGciOiJIUzI1NiIs...", + "expires_at": "2026-03-31T02:00:00Z" + }, + "meta": { "command": "auth login", "duration_ms": 120 } +} +``` + +**Flags:** +- `-u, --username` (required): Username +- `-p, --password` (required): Password +- `--save`: Guardar token en config (~/.claudia-docs.yaml) + +#### `claudia-docs auth logout` +Limpia el token guardado. + +```bash +claudia-docs auth logout +``` + +#### `claudia-docs auth status` +Muestra estado de autenticación actual. + +```bash +claudia-docs auth status +``` + +--- + +### 4.2 Document Commands + +#### `claudia-docs doc create` +Crea un nuevo documento. + +```bash +claudia-docs doc create [flags] + +# Flags: + -t, --title string Título del documento (required) + -c, --content string Contenido markdown (default: "") + -p, --project-id string ID del proyecto (required) + -f, --folder-id string ID de carpeta (optional) + -m, --metadata JSON Metadata extra como JSON (optional) + +# Ejemplo: +claudia-docs doc create \ + --title "Research: AI Trends 2026" \ + --content "# Research\n\nContenido..." \ + --project-id "uuid-proyecto" +``` + +**Output:** +```json +{ + "success": true, + "data": { + "id": "uuid-doc", + "title": "Research: AI Trends 2026", + "project_id": "uuid-proyecto", + "created_at": "2026-03-30T14:00:00Z" + } +} +``` + +--- + +#### `claudia-docs doc list` +Lista documentos. + +```bash +claudia-docs doc list [flags] + +# Flags: + -p, --project-id string Filtrar por proyecto + -f, --folder-id string Filtrar por carpeta + --limit int Límite de resultados (default: 20) + --offset int Offset para paginación (default: 0) + +# Ejemplo: +claudia-docs doc list --project-id "uuid-proyecto" --limit 10 +``` + +--- + +#### `claudia-docs doc get` +Obtiene un documento por ID. + +```bash +claudia-docs doc get [flags] + +# Flags: + --include-reasoning Incluir metadata de reasoning + --include-tags Incluir tags + +# Ejemplo: +claudia-docs doc get uuid-doc --include-reasoning --output json +``` + +--- + +#### `claudia-docs doc update` +Actualiza un documento. + +```bash +claudia-docs doc update [flags] + +# Flags: + -t, --title string Nuevo título + -c, --content string Nuevo contenido + -f, --folder-id string Mover a carpeta + +# Ejemplo: +claudia-docs doc update uuid-doc --title "Title v2" +``` + +--- + +#### `claudia-docs doc delete` +Elimina un documento. + +```bash +claudia-docs doc delete [--force] + +# --force: Skip confirmación +``` + +--- + +### 4.3 Project Commands + +#### `claudia-docs project list` +Lista proyectos del agente. + +```bash +claudia-docs project list +``` + +#### `claudia-docs project create` +Crea un proyecto. + +```bash +claudia-docs project create -n [-d ] +``` + +#### `claudia-docs project get` +Obtiene un proyecto. + +```bash +claudia-docs project get +``` + +--- + +### 4.4 Folder Commands + +#### `claudia-docs folder list` +Lista carpetas de un proyecto. + +```bash +claudia-docs folder list --project-id [--parent-id ] +``` + +#### `claudia-docs folder create` +Crea una carpeta. + +```bash +claudia-docs folder create --project-id -n [--parent-id ] +``` + +--- + +### 4.5 Tag Commands + +#### `claudia-docs tag list` +Lista todos los tags. + +#### `claudia-docs tag create` +Crea un tag. + +```bash +claudia-docs tag create -n [--color ] +``` + +#### `claudia-docs tag add` +Añade tag a documento. + +```bash +claudia-docs tag add --doc-id --tag-id +``` + +--- + +### 4.6 Search Commands + +#### `claudia-docs search` +Búsqueda full-text. + +```bash +claudia-docs search [flags] + +# Flags: + -q, --query string Texto a buscar (required) + -p, --project-id string Filtrar por proyecto + --tags string Filtrar por tags (comma-separated) + +# Ejemplo: +claudia-docs search --query "API design" --project-id uuid-proyecto +``` + +--- + +### 4.7 Reasoning Commands + +#### `claudia-docs reasoning save` +Guarda reasoning de un agente. + +```bash +claudia-docs reasoning save [flags] + +# Flags: + -d, --doc-id string Documento asociado (required) + -t, --type string Tipo: research, planning, analysis, synthesis (required) + -s, --steps JSON Array de steps de reasoning (required) + --confidence float Confidence 0.0-1.0 (optional) + --model string Modelo fuente (optional) + +# Ejemplo: +claudia-docs reasoning save \ + --doc-id uuid-doc \ + --type research \ + --steps '[{"step_id":"1","thought":"...","conclusion":"..."}]' \ + --confidence 0.85 \ + --model "claude-sonnet-4" +``` + +--- + +## 5. Output Format + +### JSON (default) + +```json +{ + "success": true|false, + "data": { ... }, + "error": { + "code": "ERROR_CODE", + "message": "Human readable message" + }, + "meta": { + "command": "doc create", + "duration_ms": 45, + "server": "http://localhost:8000" + } +} +``` + +### Text (--output text) + +``` +✅ Document created: uuid-doc + Title: Research: AI Trends 2026 + Created: 2026-03-30 14:00:00 +``` + +### Quiet Mode (--quiet) + +Solo JSON, sin mensajes de status. + +--- + +## 6. Configuration + +### File: `~/.claudia-docs.yaml` + +```yaml +server: http://localhost:8000 +token: "" # Se llena tras auth login --save +output: json +timeout: 30s + +# Agentes múltiples (para Operator) +agents: + claudia: + token: eyJhbGciOiJIUzI1NiIs... + default_project: uuid-claudia + other: + token: eyJhbGciOiJIUzI1NiIs... + default_project: uuid-other +``` + +### Environment Variables + +| Variable | Override | Description | +|----------|----------|-------------| +| `CLAUDIA_SERVER` | `--server` | API server URL | +| `CLAUDIA_TOKEN` | `--token` | JWT token | +| `CLAUDIA_OUTPUT` | `--output` | Output format | +| `CLAUDIA_AGENT` | - | Agent profile en config | + +--- + +## 7. Agent Integration + +### Ejemplo: Claudia invocando CLI + +```bash +#!/bin/bash +# Claudia save_reasoning.sh + +SERVER="${CLAUDIA_SERVER:-http://localhost:8000}" +TOKEN="${CLAUDIA_TOKEN}" + +# Login si no hay token +if [ -z "$TOKEN" ]; then + TOKEN=$(claudia-docs auth login \ + -u "${AGENT_USER}" \ + -p "${AGENT_PASS}" \ + --save \ + --output json | jq -r '.data.token') +fi + +# Guardar documento con reasoning +claudia-docs doc create \ + --title "Analysis: $TASK_ID" \ + --content "$(cat reasoning.md)" \ + --project-id "${PROJECT_ID}" \ + --output json | jq -r '.data.id' > /tmp/doc_id.txt + +# Guardar reasoning steps +claudia-docs reasoning save \ + --doc-id "$(cat /tmp/doc_id.txt)" \ + --type research \ + --steps "$(cat steps.json)" \ + --confidence 0.85 \ + --model "claude-sonnet-4" +``` + +### En contexto de agente (prompts) + +``` +[System] Para guardar reasoning, usa: + claudia-docs reasoning save --doc-id --type --steps '' + +[Tool] claudia-docs doc create --title "..." --content "..." +``` + +--- + +## 8. Project Structure + +``` +claudia-docs-cli/ +├── cmd/ +│ └── claudia-docs/ +│ └── main.go # Entry point +├── internal/ +│ ├── auth/ +│ │ └── auth.go # Login, JWT handling +│ ├── api/ +│ │ ├── client.go # HTTP client +│ │ ├── documents.go # Document API calls +│ │ ├── projects.go # Project API calls +│ │ ├── folders.go # Folder API calls +│ │ ├── tags.go # Tags API calls +│ │ ├── search.go # Search API calls +│ │ └── reasoning.go # Reasoning API calls +│ ├── config/ +│ │ └── config.go # Viper config +│ └── output/ +│ └── output.go # JSON/Text output formatting +├── pkg/ +│ └── types/ +│ └── types.go # Shared types (para compartir con backend) +├── cobra/ +│ └── commands.go # Cobra command definitions +├── Makefile +├── go.mod +├── go.sum +└── README.md +``` + +--- + +## 9. Cross-Compilation + +```bash +# Linux +GOOS=linux GOARCH=amd64 go build -o claudia-docs-linux-amd64 ./cmd/claudia-docs + +# macOS +GOOS=darwin GOARCH=amd64 go build -o claudia-docs-darwin-amd64 ./cmd/claudia-docs +GOOS=darwin GOARCH=arm64 go build -o claudia-docs-darwin-arm64 ./cmd/claudia-docs + +# Windows +GOOS=windows GOARCH=amd64 go build -o claudia-docs-windows-amd64.exe ./cmd/claudia-docs +``` + +--- + +## 10. Dependencies + +```go +// go.mod +module github.com/claudia/docs-cli + +go 1.21 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 + github.com/golang-jwt/jwt/v5 v5.2.0 +) +``` + +**Sin dependencias externas** para HTTP client ni JSON (stdlib). + +--- + +## 11. Error Codes + +| Code | HTTP | Description | +|------|------|-------------| +| `UNAUTHORIZED` | 401 | Token inválido o expirado | +| `FORBIDDEN` | 403 | Sin permisos para el recurso | +| `NOT_FOUND` | 404 | Recurso no encontrado | +| `VALIDATION_ERROR` | 422 | Datos inválidos | +| `SERVER_ERROR` | 500 | Error interno del servidor | +| `NETWORK_ERROR` | - | No se pudo conectar al servidor | + +--- + +## 12. Roadmap + +### Phase 1: Core CLI (MVP) +- [x] Auth: login, logout, status +- [x] Documents: create, list, get, update, delete +- [x] Projects: list, get +- [x] Folders: list, get +- [x] Output JSON/Text + +### Phase 2: Full Coverage +- [x] Tags: CRUD + add/remove from docs +- [x] Search +- [x] Reasoning save/list/get + +### Phase 3: Advanced +- [ ] Batch operations (crear múltiples docs) +- [ ] File upload (--file path) +- [ ] Export documents +- [ ] Interactive mode (TUI) +- [ ] Shell completion + +### Phase 4: Agent Features +- [ ] Streaming output +- [ ] Webhook subscriptions +- [ ] Agent-to-agent communication + +--- + +## 13. Open Questions + +1. **¿Go o Rust?** ¿Preferencia del equipo para Go vs Rust? +2. **Distribución:** ¿Binary-only es aceptable o se requiere npm/pip? +3. **TUI interactivo:** ¿Necesario en Phase 1 o Phase 3? +4. **Agent profiles:** ¿Soporte multi-agente desde inicio? + +--- + +## 14. Metadata + +| Campo | Valor | +|-------|-------| +| Project | Claudia Docs CLI | +| Version | 1.0.0 | +| Status | Specification | +| Created | 2026-03-30 | +| Author | Claudia (Chief Researcher) | +| Language | Go 1.21+ | +| Framework | Cobra | diff --git a/cobra/commands.go b/cobra/commands.go new file mode 100644 index 0000000..d942827 --- /dev/null +++ b/cobra/commands.go @@ -0,0 +1,746 @@ +package cobra + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/claudia/docs-cli/internal/api" + "github.com/claudia/docs-cli/internal/auth" + "github.com/claudia/docs-cli/internal/config" + "github.com/claudia/docs-cli/internal/output" + "github.com/claudia/docs-cli/pkg/types" +) + +var ( + server string + token string + outputFormat string + quiet bool + verbose bool +) + +var rootCmd = &cobra.Command{ + Use: "claudia-docs", + Short: "Claudia Docs CLI - Interact with Claudia Docs from the command line", + Long: `Claudia Docs CLI allows you to manage documents, projects, folders, tags, and more.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return initConfig() + }, +} + +func Execute() error { + return rootCmd.Execute() +} + +func initConfig() error { + cfg, err := config.Load() + if err != nil { + return err + } + config.UpdateFromFlags(server, token, outputFormat, quiet, verbose) + _ = cfg + return nil +} + +func init() { + rootCmd.PersistentFlags().StringVar(&server, "server", "", "API server URL") + rootCmd.PersistentFlags().StringVar(&token, "token", "", "JWT token") + rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "json", "Output format: json, text") + rootCmd.PersistentFlags().BoolVar(&quiet, "quiet", false, "Suppress stdout, only JSON") + rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Verbose output") + + // Auth commands + rootCmd.AddCommand(authCmd) + + // Document commands + rootCmd.AddCommand(docCmd) + + // Project commands + rootCmd.AddCommand(projectCmd) + + // Folder commands + rootCmd.AddCommand(folderCmd) + + // Tag commands + rootCmd.AddCommand(tagCmd) + + // Search command + rootCmd.AddCommand(searchCmd) + + // Reasoning command + rootCmd.AddCommand(reasoningCmd) +} + +// Auth command group +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Authentication commands", +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Login to Claudia Docs", + RunE: runAuthLogin, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Logout and clear saved token", + RunE: runAuthLogout, +} + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show authentication status", + RunE: runAuthStatus, +} + +func init() { + authCmd.AddCommand(authLoginCmd, authLogoutCmd, authStatusCmd) + authLoginCmd.Flags().StringP("username", "u", "", "Username (required)") + authLoginCmd.Flags().StringP("password", "p", "", "Password (required)") + authLoginCmd.Flags().Bool("save", false, "Save token to config file") + authLoginCmd.MarkFlagRequired("username") + authLoginCmd.MarkFlagRequired("password") +} + +func runAuthLogin(cmd *cobra.Command, args []string) error { + username, _ := cmd.Flags().GetString("username") + password, _ := cmd.Flags().GetString("password") + save, _ := cmd.Flags().GetBool("save") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + authClient := auth.NewAuthClient(client) + authResp, err := authClient.Login(username, password) + if err != nil { + return printError("Login failed", err) + } + + if save { + if err := config.SaveToken(authResp.Token); err != nil { + return printError("Failed to save token", err) + } + } + + data := map[string]interface{}{ + "token": authResp.Token, + "expires_at": authResp.ExpiresAt, + } + + printResponse(cmd, data, "auth login") + return nil +} + +func runAuthLogout(cmd *cobra.Command, args []string) error { + if err := auth.Logout(); err != nil { + return printError("Logout failed", err) + } + printResponse(cmd, map[string]string{"message": "Logged out successfully"}, "auth logout") + return nil +} + +func runAuthStatus(cmd *cobra.Command, args []string) error { + status, err := auth.Status() + if err != nil { + return printError("Status check failed", err) + } + printResponse(cmd, status, "auth status") + return nil +} + +// Document command group +var docCmd = &cobra.Command{ + Use: "doc", + Short: "Document commands", +} + +var docCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new document", + RunE: runDocCreate, +} + +var docListCmd = &cobra.Command{ + Use: "list", + Short: "List documents", + RunE: runDocList, +} + +var docGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a document by ID", + RunE: runDocGet, +} + +var docUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a document", + RunE: runDocUpdate, +} + +var docDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a document", + RunE: runDocDelete, +} + +func init() { + docCmd.AddCommand(docCreateCmd, docListCmd, docGetCmd, docUpdateCmd, docDeleteCmd) + + docCreateCmd.Flags().StringP("title", "t", "", "Document title (required)") + docCreateCmd.Flags().StringP("content", "c", "", "Document content") + docCreateCmd.Flags().StringP("project-id", "p", "", "Project ID (required)") + docCreateCmd.Flags().StringP("folder-id", "f", "", "Folder ID") + docCreateCmd.Flags().String("metadata", "", "Extra metadata as JSON") + docCreateCmd.MarkFlagRequired("title") + docCreateCmd.MarkFlagRequired("project-id") + + docListCmd.Flags().StringP("project-id", "p", "", "Filter by project") + docListCmd.Flags().StringP("folder-id", "f", "", "Filter by folder") + docListCmd.Flags().Int("limit", 20, "Limit results") + docListCmd.Flags().Int("offset", 0, "Offset for pagination") + + docGetCmd.Flags().Bool("include-reasoning", false, "Include reasoning metadata") + docGetCmd.Flags().Bool("include-tags", false, "Include tags") + + docUpdateCmd.Flags().StringP("title", "t", "", "New title") + docUpdateCmd.Flags().StringP("content", "c", "", "New content") + docUpdateCmd.Flags().StringP("folder-id", "f", "", "Move to folder") + + docDeleteCmd.Flags().Bool("force", false, "Skip confirmation") +} + +func runDocCreate(cmd *cobra.Command, args []string) error { + title, _ := cmd.Flags().GetString("title") + content, _ := cmd.Flags().GetString("content") + projectID, _ := cmd.Flags().GetString("project-id") + folderID, _ := cmd.Flags().GetString("folder-id") + metadataJSON, _ := cmd.Flags().GetString("metadata") + + var metadata map[string]interface{} + if metadataJSON != "" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return printError("Invalid metadata JSON", err) + } + } + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + docsClient := api.NewDocumentsClient(client) + req := types.CreateDocumentRequest{ + Title: title, + Content: content, + FolderID: folderID, + Metadata: metadata, + } + + doc, err := docsClient.Create(projectID, req) + if err != nil { + return printError("Failed to create document", err) + } + + printResponse(cmd, doc, "doc create") + return nil +} + +func runDocList(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project-id") + folderID, _ := cmd.Flags().GetString("folder-id") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + docsClient := api.NewDocumentsClient(client) + docs, err := docsClient.List(projectID, folderID, limit, offset) + if err != nil { + return printError("Failed to list documents", err) + } + + printResponse(cmd, docs, "doc list") + return nil +} + +func runDocGet(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("document ID required") + } + docID := args[0] + + includeReasoning, _ := cmd.Flags().GetBool("include-reasoning") + includeTags, _ := cmd.Flags().GetBool("include-tags") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + docsClient := api.NewDocumentsClient(client) + doc, err := docsClient.Get(docID, includeReasoning, includeTags) + if err != nil { + return printError("Failed to get document", err) + } + + printResponse(cmd, doc, "doc get") + return nil +} + +func runDocUpdate(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("document ID required") + } + docID := args[0] + + title, _ := cmd.Flags().GetString("title") + content, _ := cmd.Flags().GetString("content") + folderID, _ := cmd.Flags().GetString("folder-id") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + docsClient := api.NewDocumentsClient(client) + req := types.UpdateDocumentRequest{ + Title: title, + Content: content, + FolderID: folderID, + } + + doc, err := docsClient.Update(docID, req) + if err != nil { + return printError("Failed to update document", err) + } + + printResponse(cmd, doc, "doc update") + return nil +} + +func runDocDelete(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("document ID required") + } + docID := args[0] + + force, _ := cmd.Flags().GetBool("force") + if !force { + fmt.Printf("Are you sure you want to delete document %s? (y/N): ", docID) + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Cancelled") + return nil + } + } + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + docsClient := api.NewDocumentsClient(client) + if err := docsClient.Delete(docID); err != nil { + return printError("Failed to delete document", err) + } + + printResponse(cmd, map[string]string{"id": docID, "deleted": "true"}, "doc delete") + return nil +} + +// Project command group +var projectCmd = &cobra.Command{ + Use: "project", + Short: "Project commands", +} + +var projectListCmd = &cobra.Command{ + Use: "list", + Short: "List projects", + RunE: runProjectList, +} + +var projectGetCmd = &cobra.Command{ + Use: "get", + Short: "Get a project by ID", + RunE: runProjectGet, +} + +func init() { + projectCmd.AddCommand(projectListCmd, projectGetCmd) +} + +func runProjectList(cmd *cobra.Command, args []string) error { + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + projectsClient := api.NewProjectsClient(client) + projects, err := projectsClient.List() + if err != nil { + return printError("Failed to list projects", err) + } + + printResponse(cmd, projects, "project list") + return nil +} + +func runProjectGet(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("project ID required") + } + projectID := args[0] + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + projectsClient := api.NewProjectsClient(client) + project, err := projectsClient.Get(projectID) + if err != nil { + return printError("Failed to get project", err) + } + + printResponse(cmd, project, "project get") + return nil +} + +// Folder command group +var folderCmd = &cobra.Command{ + Use: "folder", + Short: "Folder commands", +} + +var folderListCmd = &cobra.Command{ + Use: "list", + Short: "List folders in a project", + RunE: runFolderList, +} + +var folderCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a folder", + RunE: runFolderCreate, +} + +func init() { + folderCmd.AddCommand(folderListCmd, folderCreateCmd) + + folderListCmd.Flags().StringP("project-id", "p", "", "Project ID (required)") + folderListCmd.Flags().String("parent-id", "", "Parent folder ID") + folderListCmd.MarkFlagRequired("project-id") + + folderCreateCmd.Flags().StringP("project-id", "p", "", "Project ID (required)") + folderCreateCmd.Flags().StringP("name", "n", "", "Folder name (required)") + folderCreateCmd.Flags().String("parent-id", "", "Parent folder ID") + folderCreateCmd.MarkFlagRequired("project-id") + folderCreateCmd.MarkFlagRequired("name") +} + +func runFolderList(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project-id") + parentID, _ := cmd.Flags().GetString("parent-id") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + foldersClient := api.NewFoldersClient(client) + folders, err := foldersClient.List(projectID, parentID) + if err != nil { + return printError("Failed to list folders", err) + } + + printResponse(cmd, folders, "folder list") + return nil +} + +func runFolderCreate(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project-id") + name, _ := cmd.Flags().GetString("name") + parentID, _ := cmd.Flags().GetString("parent-id") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + foldersClient := api.NewFoldersClient(client) + req := types.CreateFolderRequest{ + Name: name, + ParentID: parentID, + } + + folder, err := foldersClient.Create(projectID, req) + if err != nil { + return printError("Failed to create folder", err) + } + + printResponse(cmd, folder, "folder create") + return nil +} + +// Tag command group +var tagCmd = &cobra.Command{ + Use: "tag", + Short: "Tag commands", +} + +var tagListCmd = &cobra.Command{ + Use: "list", + Short: "List all tags", + RunE: runTagList, +} + +var tagCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a tag", + RunE: runTagCreate, +} + +var tagAddCmd = &cobra.Command{ + Use: "add", + Short: "Add a tag to a document", + RunE: runTagAdd, +} + +func init() { + tagCmd.AddCommand(tagListCmd, tagCreateCmd, tagAddCmd) + + tagCreateCmd.Flags().StringP("name", "n", "", "Tag name (required)") + tagCreateCmd.Flags().String("color", "", "Tag color (hex)") + tagCreateCmd.MarkFlagRequired("name") + + tagAddCmd.Flags().String("doc-id", "", "Document ID (required)") + tagAddCmd.Flags().String("tag-id", "", "Tag ID (required)") + tagAddCmd.MarkFlagRequired("doc-id") + tagAddCmd.MarkFlagRequired("tag-id") +} + +func runTagList(cmd *cobra.Command, args []string) error { + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + tagsClient := api.NewTagsClient(client) + tags, err := tagsClient.List() + if err != nil { + return printError("Failed to list tags", err) + } + + printResponse(cmd, tags, "tag list") + return nil +} + +func runTagCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + color, _ := cmd.Flags().GetString("color") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + tagsClient := api.NewTagsClient(client) + req := types.CreateTagRequest{ + Name: name, + Color: color, + } + + tag, err := tagsClient.Create(req) + if err != nil { + return printError("Failed to create tag", err) + } + + printResponse(cmd, tag, "tag create") + return nil +} + +func runTagAdd(cmd *cobra.Command, args []string) error { + docID, _ := cmd.Flags().GetString("doc-id") + tagID, _ := cmd.Flags().GetString("tag-id") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + tagsClient := api.NewTagsClient(client) + if err := tagsClient.AddToDocument(docID, tagID); err != nil { + return printError("Failed to add tag", err) + } + + printResponse(cmd, map[string]string{"doc_id": docID, "tag_id": tagID, "added": "true"}, "tag add") + return nil +} + +// Search command +var searchCmd = &cobra.Command{ + Use: "search", + Short: "Search documents", + RunE: runSearch, +} + +func init() { + searchCmd.Flags().StringP("query", "q", "", "Search query (required)") + searchCmd.Flags().StringP("project-id", "p", "", "Filter by project") + searchCmd.Flags().String("tags", "", "Filter by tags (comma-separated)") + searchCmd.MarkFlagRequired("query") +} + +func runSearch(cmd *cobra.Command, args []string) error { + query, _ := cmd.Flags().GetString("query") + projectID, _ := cmd.Flags().GetString("project-id") + tags, _ := cmd.Flags().GetString("tags") + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + searchClient := api.NewSearchClient(client) + result, err := searchClient.Search(query, projectID, tags) + if err != nil { + return printError("Search failed", err) + } + + printResponse(cmd, result, "search") + return nil +} + +// Reasoning command +var reasoningCmd = &cobra.Command{ + Use: "reasoning", + Short: "Reasoning commands", +} + +var reasoningSaveCmd = &cobra.Command{ + Use: "save", + Short: "Save reasoning for a document", + RunE: runReasoningSave, +} + +func init() { + reasoningCmd.AddCommand(reasoningSaveCmd) + + reasoningSaveCmd.Flags().StringP("doc-id", "d", "", "Document ID (required)") + reasoningSaveCmd.Flags().StringP("type", "t", "", "Reasoning type: research, planning, analysis, synthesis (required)") + reasoningSaveCmd.Flags().String("steps", "", "JSON array of reasoning steps (required)") + reasoningSaveCmd.Flags().Float64("confidence", 0.0, "Confidence score 0.0-1.0") + reasoningSaveCmd.Flags().String("model", "", "Source model") + reasoningSaveCmd.MarkFlagRequired("doc-id") + reasoningSaveCmd.MarkFlagRequired("type") + reasoningSaveCmd.MarkFlagRequired("steps") +} + +func runReasoningSave(cmd *cobra.Command, args []string) error { + docID, _ := cmd.Flags().GetString("doc-id") + reasoningType, _ := cmd.Flags().GetString("type") + stepsJSON, _ := cmd.Flags().GetString("steps") + confidence, _ := cmd.Flags().GetFloat64("confidence") + model, _ := cmd.Flags().GetString("model") + + var steps []types.ReasoningStep + if err := json.Unmarshal([]byte(stepsJSON), &steps); err != nil { + return printError("Invalid steps JSON", err) + } + + client, err := api.NewClient() + if err != nil { + return printError("Failed to create client", err) + } + + reasoningClient := api.NewReasoningClient(client) + req := types.SaveReasoningRequest{ + Type: reasoningType, + Steps: steps, + Confidence: confidence, + Model: model, + } + + reasoning, err := reasoningClient.Save(docID, req) + if err != nil { + return printError("Failed to save reasoning", err) + } + + printResponse(cmd, reasoning, "reasoning save") + return nil +} + +// Helper functions +func printResponse(cmd *cobra.Command, data interface{}, command string) { + start := time.Now() + cfg := config.GetConfig() + server := "http://localhost:8000" + if cfg != nil { + server = cfg.Server + } + + formatter := output.NewFormatter(command, server) + durationMs := int64(time.Since(start).Milliseconds()) + resp := formatter.Success(data, durationMs) + + if cfg != nil && cfg.Output == "text" { + printTextOutput(command, data) + } else { + output.PrintJSON(resp) + } +} + +func printTextOutput(command string, data interface{}) { + switch v := data.(type) { + case map[string]interface{}: + for k, val := range v { + fmt.Printf("%s: %v\n", k, val) + } + case []interface{}: + fmt.Println("Items:") + for i, item := range v { + fmt.Printf(" [%d] %v\n", i, item) + } + default: + fmt.Println(data) + } +} + +func printError(context string, err error) error { + cfg := config.GetConfig() + server := "http://localhost:8000" + if cfg != nil { + server = cfg.Server + } + + formatter := output.NewFormatter("", server) + durationMs := int64(0) + + code := "ERROR" + if err != nil { + code = "NETWORK_ERROR" + } + + resp := formatter.Error(code, err.Error(), durationMs) + + if cfg != nil && cfg.Output == "text" { + fmt.Fprintf(os.Stderr, "%s: %v\n", context, err) + } else { + output.PrintJSON(resp) + } + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..43c1e0c --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/claudia/docs-cli + +go 1.21 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b6a7dcc --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..aa2a871 --- /dev/null +++ b/internal/api/client.go @@ -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)) +} diff --git a/internal/api/documents.go b/internal/api/documents.go new file mode 100644 index 0000000..bb0aa21 --- /dev/null +++ b/internal/api/documents.go @@ -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 +} diff --git a/internal/api/folders.go b/internal/api/folders.go new file mode 100644 index 0000000..e4c1eee --- /dev/null +++ b/internal/api/folders.go @@ -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 +} diff --git a/internal/api/projects.go b/internal/api/projects.go new file mode 100644 index 0000000..8e7d937 --- /dev/null +++ b/internal/api/projects.go @@ -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 +} diff --git a/internal/api/reasoning.go b/internal/api/reasoning.go new file mode 100644 index 0000000..c8948fd --- /dev/null +++ b/internal/api/reasoning.go @@ -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 +} diff --git a/internal/api/search.go b/internal/api/search.go new file mode 100644 index 0000000..d2348c2 --- /dev/null +++ b/internal/api/search.go @@ -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 +} diff --git a/internal/api/tags.go b/internal/api/tags.go new file mode 100644 index 0000000..5aa52cf --- /dev/null +++ b/internal/api/tags.go @@ -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 +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..382e11b --- /dev/null +++ b/internal/auth/auth.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8355dbe --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..61616fc --- /dev/null +++ b/internal/output/output.go @@ -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 + } +} diff --git a/pkg/types/types.go b/pkg/types/types.go new file mode 100644 index 0000000..86d520f --- /dev/null +++ b/pkg/types/types.go @@ -0,0 +1,149 @@ +package types + +import "time" + +// Response is the standard API response wrapper +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` + Meta MetaInfo `json:"meta"` +} + +// ErrorInfo contains error details +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// MetaInfo contains metadata about the response +type MetaInfo struct { + Command string `json:"command"` + DurationMs int64 `json:"duration_ms"` + Server string `json:"server,omitempty"` +} + +// AuthResponse is the response from auth endpoints +type AuthResponse struct { + Token string `json:"access_token,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + TokenType string `json:"token_type,omitempty"` +} + +// Document represents a document +type Document struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content,omitempty"` + ProjectID string `json:"project_id"` + FolderID string `json:"folder_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Tags []Tag `json:"tags,omitempty"` + Reasoning []ReasoningStep `json:"reasoning,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Project represents a project +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Folder represents a folder +type Folder struct { + ID string `json:"id"` + Name string `json:"name"` + ProjectID string `json:"project_id"` + ParentID string `json:"parent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Tag represents a tag +type Tag struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +// ReasoningStep represents a reasoning step +type ReasoningStep struct { + StepID string `json:"step_id"` + Thought string `json:"thought"` + Conclusion string `json:"conclusion,omitempty"` + Action string `json:"action,omitempty"` +} + +// Reasoning represents reasoning metadata for a document +type Reasoning struct { + DocID string `json:"doc_id"` + Type string `json:"type"` + Steps []ReasoningStep `json:"steps"` + Confidence float64 `json:"confidence,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// SearchResult represents a search result +type SearchResult struct { + Documents []Document `json:"documents"` + Total int `json:"total"` + Query string `json:"query"` +} + +// CreateDocumentRequest is the request body for creating a document +type CreateDocumentRequest struct { + Title string `json:"title"` + Content string `json:"content"` + FolderID string `json:"folder_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// UpdateDocumentRequest is the request body for updating a document +type UpdateDocumentRequest struct { + Title string `json:"title,omitempty"` + Content string `json:"content,omitempty"` + FolderID string `json:"folder_id,omitempty"` +} + +// CreateProjectRequest is the request body for creating a project +type CreateProjectRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// CreateFolderRequest is the request body for creating a folder +type CreateFolderRequest struct { + Name string `json:"name"` + ParentID string `json:"parent_id,omitempty"` +} + +// CreateTagRequest is the request body for creating a tag +type CreateTagRequest struct { + Name string `json:"name"` + Color string `json:"color,omitempty"` +} + +// SaveReasoningRequest is the request body for saving reasoning +type SaveReasoningRequest struct { + Type string `json:"type"` + Steps []ReasoningStep `json:"steps"` + Confidence float64 `json:"confidence,omitempty"` + Model string `json:"model,omitempty"` +} + +// LoginRequest is the request body for login +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// RegisterRequest is the request body for registration +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` +}