package cv import ( "bytes" "context" "database/sql" "embed" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "time" "github.com/cobr-ai/apex/internal/eval" ) //go:embed prompts/tailor.md templates/cv-template.html var embeddedFS embed.FS // CVResult holds the generated file paths. type CVResult struct { HTMLPath string PDFPath string } // HTMLExporter generates a tailored HTML CV + PDF via Chromium. type HTMLExporter struct { DB *sql.DB CVPath string // path to cv.md (defaults to ~/.apex/cv.md) OutputDir string // defaults to ~/.apex/cv/ } // Generate creates a tailored HTML CV (and optionally PDF) for the given job URL. // Flow: query DB for JD → read cv.md → load profile → read tailor prompt → // call Claude → render HTML → call Chromium for PDF. func (e *HTMLExporter) Generate(ctx context.Context, jobURL string) (CVResult, error) { cvPath := e.CVPath if cvPath == "" { home, err := os.UserHomeDir() if err != nil { return CVResult{}, fmt.Errorf("expand home: %w", err) } cvPath = filepath.Join(home, ".apex", "cv.md") } outputDir := e.OutputDir if outputDir == "" { home, err := os.UserHomeDir() if err != nil { return CVResult{}, fmt.Errorf("expand home: %w", err) } outputDir = filepath.Join(home, ".apex", "cv") } // Ensure output directory exists if err := os.MkdirAll(outputDir, 0700); err != nil { return CVResult{}, fmt.Errorf("mkdir %s: %w", outputDir, err) } // Query jobs table for company, title, description by URL var company, title, description string err := e.DB.QueryRowContext(ctx, ` SELECT company, title, COALESCE(description, '') FROM jobs WHERE url = ? LIMIT 1 `, jobURL).Scan(&company, &title, &description) if err != nil { if err == sql.ErrNoRows { return CVResult{}, fmt.Errorf("job not found: %s", jobURL) } return CVResult{}, fmt.Errorf("query job: %w", err) } // Read cv.md cvContent, err := os.ReadFile(cvPath) if err != nil { return CVResult{}, fmt.Errorf("read cv.md: %w", err) } // Load profile from DB var name, email, location string err = e.DB.QueryRowContext(ctx, ` SELECT name, email, location FROM profile LIMIT 1 `).Scan(&name, &email, &location) if err != nil && err != sql.ErrNoRows { return CVResult{}, fmt.Errorf("query profile: %w", err) } // Read tailor.md prompt template promptTpl, err := embeddedFS.ReadFile("prompts/tailor.md") if err != nil { return CVResult{}, fmt.Errorf("read tailor prompt: %w", err) } // Substitute placeholders in prompt prompt := strings.NewReplacer( "{{CV}}", string(cvContent), "{{JD}}", description, "{{NAME}}", name, "{{EMAIL}}", email, "{{LOCATION}}", location, ).Replace(string(promptTpl)) // Call Claude CLI to generate tailored CV HTML html, err := e.callClaude(ctx, prompt) if err != nil { return CVResult{}, fmt.Errorf("call claude: %w", err) } // Generate filenames now := time.Now().Format("2006-01-02") companySlug := eval.Slugify(company) nameSlug := eval.Slugify(name) baseFilename := fmt.Sprintf("cv-%s-%s-%s", nameSlug, companySlug, now) htmlPath := filepath.Join(outputDir, baseFilename+".html") pdfPath := filepath.Join(outputDir, baseFilename+".pdf") // Write HTML file if err := os.WriteFile(htmlPath, []byte(html), 0600); err != nil { return CVResult{}, fmt.Errorf("write html: %w", err) } // Generate PDF via Chromium if available if err := e.generatePDF(htmlPath, pdfPath); err != nil { // PDF generation is best-effort; don't fail the entire operation fmt.Fprintf(os.Stderr, "pdf generation failed (continuing with HTML only): %v\n", err) } return CVResult{ HTMLPath: htmlPath, PDFPath: pdfPath, }, nil } // callClaude runs the Claude CLI with the given prompt and returns raw HTML output. // Timeout: 10 minutes for full document generation. func (e *HTMLExporter) callClaude(ctx context.Context, prompt string) (string, error) { timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() cmd := exec.CommandContext(timeoutCtx, "claude", "--print", "--output-format", "text") stdin, err := cmd.StdinPipe() if err != nil { return "", fmt.Errorf("stdin pipe: %w", err) } stdout, err := cmd.StdoutPipe() if err != nil { return "", fmt.Errorf("stdout pipe: %w", err) } var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { return "", fmt.Errorf("start claude: %w", err) } // Feed prompt via stdin in a goroutine go func() { defer stdin.Close() _, _ = io.WriteString(stdin, prompt) }() // Read output output, err := io.ReadAll(stdout) if err != nil { return "", fmt.Errorf("read stdout: %w", err) } if err := cmd.Wait(); err != nil { if timeoutCtx.Err() == context.DeadlineExceeded { return "", errors.New("claude timeout (10 minutes)") } return "", fmt.Errorf("claude exit: %w (stderr: %s)", err, stderr.String()) } html := string(output) if strings.TrimSpace(html) == "" { return "", errors.New("claude returned empty output") } return html, nil } // generatePDF uses Chromium to convert HTML to PDF. // Returns early if chromium not found; it's optional. func (e *HTMLExporter) generatePDF(htmlPath, pdfPath string) error { // Resolve symlinks on the output directory (file doesn't exist yet so we // can only canonicalize the dir). This prevents symlink traversal bypasses // that filepath.Clean alone would miss. realDir, err := filepath.EvalSymlinks(filepath.Dir(htmlPath)) if err != nil { realDir = filepath.Clean(filepath.Dir(htmlPath)) } absPDF := filepath.Clean(pdfPath) if !strings.HasPrefix(absPDF, realDir+string(os.PathSeparator)) { return fmt.Errorf("pdf path escapes output directory") } chromiumPath := "/usr/bin/chromium" if _, err := os.Stat(chromiumPath); err != nil { return fmt.Errorf("chromium not found at %s", chromiumPath) } // Convert to absolute path if needed absHTMLPath, err := filepath.Abs(htmlPath) if err != nil { absHTMLPath = htmlPath } // Chromium needs file:// URL fileURL := "file://" + absHTMLPath ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, chromiumPath, "--headless=new", "--disable-gpu", "--no-sandbox", fmt.Sprintf("--print-to-pdf=%s", pdfPath), fileURL, ) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("chromium: %w (stderr: %s)", err, stderr.String()) } return nil }