1587 lines
43 KiB
Go
1587 lines
43 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"encoding/json"
|
|
|
|
"github.com/cobr-ai/apex/internal/cv"
|
|
"github.com/cobr-ai/apex/internal/docx"
|
|
"github.com/cobr-ai/apex/internal/eval"
|
|
"github.com/cobr-ai/apex/internal/followup"
|
|
"github.com/cobr-ai/apex/internal/intake"
|
|
"github.com/cobr-ai/apex/internal/linkedin"
|
|
"github.com/cobr-ai/apex/internal/patterns"
|
|
"github.com/cobr-ai/apex/internal/scan"
|
|
"github.com/cobr-ai/apex/internal/store"
|
|
)
|
|
|
|
// runScan headless: load all enabled adapters, run them, write jobs to DB.
|
|
func runScan(args []string) int {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "scan: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
|
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "scan: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
// Load config from repo root
|
|
root := findRepoRoot()
|
|
cfgPath := filepath.Join(root, "internal", "scan", "adapters.yaml")
|
|
cfg, err := scan.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "scan: config load: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
runner := scan.NewRunner(db, cfg)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
|
|
for result := range runner.RunAll(ctx, cfg.TrackedCompanies, cfg.Aggregators) {
|
|
if result.Err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: %v\n", result.Company, result.Err)
|
|
} else {
|
|
fmt.Printf("%s: %d found, %d filtered, %d new (%dms)\n",
|
|
result.Company, result.Found, result.Filtered, result.New, result.DurationMs)
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// runEval headless: load pending jobs, evaluate via Claude, write reports.
|
|
func runEval(args []string) int {
|
|
// Parse --limit, --min-score, and --workers flags
|
|
limit := 0
|
|
minScore := 0.0
|
|
workers := eval.DefaultConcurrency
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--limit":
|
|
if i+1 < len(args) {
|
|
v, err := strconv.Atoi(args[i+1])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: invalid --limit: %v\n", err)
|
|
return 2
|
|
}
|
|
limit = v
|
|
i++
|
|
}
|
|
case "--min-score":
|
|
if i+1 < len(args) {
|
|
v, err := strconv.ParseFloat(args[i+1], 64)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: invalid --min-score: %v\n", err)
|
|
return 2
|
|
}
|
|
minScore = v
|
|
i++
|
|
}
|
|
case "--workers":
|
|
if i+1 < len(args) {
|
|
v, err := strconv.Atoi(args[i+1])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: invalid --workers: %v\n", err)
|
|
return 2
|
|
}
|
|
if v < 1 {
|
|
v = 1
|
|
}
|
|
if v > 10 {
|
|
v = 10
|
|
}
|
|
workers = v
|
|
i++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check Claude CLI availability
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: claude not available: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
reportsDir := filepath.Join(home, ".apex", "reports")
|
|
cvPath := filepath.Join(home, ".apex", "cv.md")
|
|
|
|
writer := &eval.ReportWriter{
|
|
DB: db,
|
|
ReportsDir: reportsDir,
|
|
}
|
|
|
|
// Load pending jobs
|
|
jobs, err := loadPendingJobs(db, limit)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "eval: load pending: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if len(jobs) == 0 {
|
|
fmt.Println("eval: no pending jobs")
|
|
return 0
|
|
}
|
|
|
|
batch := &eval.Batch{
|
|
Client: client,
|
|
Writer: writer,
|
|
DB: db,
|
|
CVPath: cvPath,
|
|
Concurrency: workers,
|
|
MinScore: minScore,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
|
defer cancel()
|
|
|
|
// ponytail: ANSI progress display; no color library
|
|
statsEval := 0
|
|
statsSkipped := 0
|
|
statsErrors := 0
|
|
|
|
// Stream events and emit progress display
|
|
for ev := range batch.Run(ctx, jobs) {
|
|
switch ev.Kind {
|
|
case eval.EventStart:
|
|
fmt.Printf("[ ... ] evaluating: %s — %s\n", ev.Job.Company, ev.Job.Title)
|
|
case eval.EventDone:
|
|
statsEval++
|
|
fmt.Printf("[ OK ] %.1f/5 — %s — %s\n", ev.Eval.Score, ev.Job.Company, ev.Job.Title)
|
|
case eval.EventSkipped:
|
|
statsSkipped++
|
|
fmt.Printf("[SKIP ] score below threshold — %s — %s\n", ev.Job.Company, ev.Job.Title)
|
|
case eval.EventError:
|
|
statsErrors++
|
|
fmt.Printf("[FAIL ] %s — %s: %v\n", ev.Job.Company, ev.Job.Title, ev.Err)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nEvaluated: %d Skipped: %d Errors: %d\n", statsEval, statsSkipped, statsErrors)
|
|
|
|
return 0
|
|
}
|
|
|
|
// loadPendingJobs returns jobs that do not yet have an application row, up
|
|
// to limit. Mirrors internal/tui/eval_views.go:62 so headless eval and the
|
|
// TUI eval tab share the same notion of "pending".
|
|
func loadPendingJobs(db *sql.DB, limit int) ([]eval.JobContext, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
rows, err := db.Query(`
|
|
SELECT j.url, j.company, j.title, COALESCE(j.description, ''), COALESCE(j.source, '')
|
|
FROM jobs j
|
|
LEFT JOIN applications a ON a.job_id = j.id
|
|
WHERE a.id IS NULL AND j.archived = 0
|
|
ORDER BY j.discovered_at DESC
|
|
LIMIT ?
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobs []eval.JobContext
|
|
for rows.Next() {
|
|
var j eval.JobContext
|
|
if err := rows.Scan(&j.URL, &j.Company, &j.Title, &j.JDText, &j.Source); err != nil {
|
|
return nil, err
|
|
}
|
|
jobs = append(jobs, j)
|
|
}
|
|
return jobs, rows.Err()
|
|
}
|
|
|
|
// withLinkedInClient creates a client, runs fn, and always calls Close.
|
|
func withLinkedInClient(ctx context.Context, fn func(*linkedin.Client) error) error {
|
|
cli, err := linkedin.NewClient(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cli.Close()
|
|
return fn(cli)
|
|
}
|
|
|
|
// validateLinkedInURL rejects non-https or non-linkedin.com URLs to prevent
|
|
// browser navigation to attacker-controlled destinations.
|
|
func validateLinkedInURL(raw string) error {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
if u.Scheme != "https" {
|
|
return fmt.Errorf("URL must use https scheme, got %q", u.Scheme)
|
|
}
|
|
host := u.Hostname()
|
|
if host != "www.linkedin.com" && !strings.HasSuffix(host, ".linkedin.com") {
|
|
return fmt.Errorf("URL must be on linkedin.com, got %q", host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runOffers compares multiple job offers side-by-side with Claude ranking.
|
|
func runOffers(args []string) int {
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex offers <url> <url> [url...]")
|
|
return 2
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "offers: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "offers: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
type jobOffer struct {
|
|
Company string
|
|
Title string
|
|
Description string
|
|
Report string
|
|
}
|
|
|
|
var offers []jobOffer
|
|
|
|
// Load each job and its most recent report
|
|
for _, jobURL := range args {
|
|
var jobID int64
|
|
err := db.QueryRow(`SELECT id FROM jobs WHERE url = ?`, jobURL).Scan(&jobID)
|
|
if err == sql.ErrNoRows {
|
|
fmt.Fprintf(os.Stderr, "warning: no job found for %s — skipping\n", jobURL)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: query job %s: %v — skipping\n", jobURL, err)
|
|
continue
|
|
}
|
|
|
|
var company, title, description, reportMD string
|
|
err = db.QueryRow(`
|
|
SELECT j.company, j.title, COALESCE(j.description,'')
|
|
FROM jobs j
|
|
WHERE j.id = ?
|
|
`, jobID).Scan(&company, &title, &description)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: query job details for %s: %v — skipping\n", jobURL, err)
|
|
continue
|
|
}
|
|
|
|
// Query most recent report for this job
|
|
err = db.QueryRow(`
|
|
SELECT COALESCE(r.report_md, '')
|
|
FROM reports r
|
|
JOIN applications a ON a.id = r.application_id
|
|
WHERE a.job_id = ?
|
|
ORDER BY r.id DESC
|
|
LIMIT 1
|
|
`, jobID).Scan(&reportMD)
|
|
if err == sql.ErrNoRows {
|
|
fmt.Fprintf(os.Stderr, "warning: no eval report for %s — run 'apex eval' first, skipping\n", jobURL)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: query report for %s: %v — skipping\n", jobURL, err)
|
|
continue
|
|
}
|
|
|
|
if reportMD == "" {
|
|
fmt.Fprintf(os.Stderr, "warning: no eval report for %s — run 'apex eval' first, skipping\n", jobURL)
|
|
continue
|
|
}
|
|
|
|
offers = append(offers, jobOffer{
|
|
Company: company,
|
|
Title: title,
|
|
Description: description,
|
|
Report: reportMD,
|
|
})
|
|
}
|
|
|
|
if len(offers) < 2 {
|
|
fmt.Fprintln(os.Stderr, "need at least 2 evaluated jobs to compare")
|
|
return 1
|
|
}
|
|
|
|
// Build comparison prompt
|
|
prompt := "Compare these job opportunities side-by-side and provide a ranked recommendation.\n\n" +
|
|
"For each job, an evaluation report is provided below.\n\n" +
|
|
"Output format:\n" +
|
|
"1. Ranked table: Rank | Company | Role | Fit Score | Comp Potential | Culture Risk | Verdict\n" +
|
|
"2. For each job: one paragraph rationale (3-5 sentences)\n" +
|
|
"3. Final recommendation: which to prioritize and why\n\n" +
|
|
"Jobs to compare:\n"
|
|
|
|
for _, offer := range offers {
|
|
prompt += fmt.Sprintf("## %s — %s\n\n%s\n\n", offer.Company, offer.Title, offer.Report)
|
|
}
|
|
|
|
// Call Claude
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "offers: claude not available: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
result, err := client.Evaluate(ctx, prompt, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "offers: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Println(result.Raw)
|
|
return 0
|
|
}
|
|
|
|
// runLinkedIn dispatches apex linkedin subcommands.
|
|
func runLinkedIn(args []string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin <messages|find|thread|reply|invite|jobs|contacts|test|probe-thread|login>")
|
|
fmt.Fprintln(os.Stderr, " messages list recruiter message threads")
|
|
fmt.Fprintln(os.Stderr, " find <name> scroll inbox to a sender, return thread URL")
|
|
fmt.Fprintln(os.Stderr, " thread <url> read a message thread")
|
|
fmt.Fprintln(os.Stderr, " reply <url> <message> send a reply (requires confirmation)")
|
|
fmt.Fprintln(os.Stderr, " invite <url> <note> [--yes] send a connection request with a custom note (≤200 chars)")
|
|
fmt.Fprintln(os.Stderr, " jobs <keywords> [--location <loc>] [--remote] search jobs")
|
|
fmt.Fprintln(os.Stderr, " contacts <company> find recruiters at a company")
|
|
fmt.Fprintln(os.Stderr, " test test selectors and auth state")
|
|
fmt.Fprintln(os.Stderr, " probe-thread <url> dump DOM selectors from a thread (use when thread is broken)")
|
|
fmt.Fprintln(os.Stderr, " login open browser to complete LinkedIn login (requires display)")
|
|
return 2
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
sub := args[0]
|
|
rest := args[1:]
|
|
|
|
var runErr error
|
|
|
|
switch sub {
|
|
case "test":
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
if err := cli.TestSelectors(ctx); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("OK: selectors pass")
|
|
return nil
|
|
})
|
|
|
|
case "find":
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin find <name>")
|
|
return 2
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
url, err := cli.FindThread(ctx, strings.Join(rest, " "))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(url)
|
|
return nil
|
|
})
|
|
|
|
case "probe-buttons":
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin probe-buttons <url>")
|
|
return 2
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
info, err := cli.ProbeButtons(ctx, rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(info)
|
|
return nil
|
|
})
|
|
|
|
case "probe-thread":
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin probe-thread <url>")
|
|
return 2
|
|
}
|
|
if err := validateLinkedInURL(rest[0]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "linkedin probe-thread: %v\n", err)
|
|
return 1
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
info, err := cli.ProbeThread(ctx, rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Println(info)
|
|
return nil
|
|
})
|
|
|
|
case "messages":
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
threads, err := cli.ListMessages(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, t := range threads {
|
|
fmt.Printf("%d. %s\n %s\n %s\n\n", i+1, t.Sender, t.Preview, t.URL)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
case "thread":
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin thread <url>")
|
|
return 2
|
|
}
|
|
if err := validateLinkedInURL(rest[0]); err != nil {
|
|
fmt.Fprintf(os.Stderr, "linkedin thread: %v\n", err)
|
|
return 2
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
thread, err := cli.ReadThread(ctx, rest[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Thread with: %s\n\n", thread.Sender)
|
|
for _, m := range thread.Messages {
|
|
fmt.Printf("[%s] %s\n%s\n\n", m.Timestamp, m.Author, m.Text)
|
|
}
|
|
msgType := linkedin.ClassifyMessage(*thread)
|
|
missing := linkedin.MissingFields(*thread)
|
|
fmt.Printf("--- classification: %s", msgTypeLabel(msgType))
|
|
if len(missing) > 0 {
|
|
fmt.Printf(" | missing: %s", strings.Join(missing, ", "))
|
|
draft := linkedin.DraftInfoRequest(*thread, missing)
|
|
fmt.Printf("\n--- suggested reply:\n%s", draft)
|
|
}
|
|
fmt.Println()
|
|
return nil
|
|
})
|
|
|
|
case "reply":
|
|
if len(rest) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin reply <url> <message>")
|
|
return 2
|
|
}
|
|
threadURL := rest[0]
|
|
if err := validateLinkedInURL(threadURL); err != nil {
|
|
fmt.Fprintf(os.Stderr, "linkedin reply: %v\n", err)
|
|
return 2
|
|
}
|
|
message := strings.Join(rest[1:], " ")
|
|
if !promptConfirm(threadURL, message, false) {
|
|
fmt.Println("cancelled")
|
|
return 0
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
if err := cli.Reply(ctx, threadURL, message); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("sent")
|
|
return nil
|
|
})
|
|
|
|
case "invite":
|
|
if len(rest) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin invite <url> <note> [--yes]")
|
|
return 2
|
|
}
|
|
profileURL := rest[0]
|
|
if err := validateLinkedInURL(profileURL); err != nil {
|
|
fmt.Fprintf(os.Stderr, "linkedin invite: %v\n", err)
|
|
return 2
|
|
}
|
|
note := strings.Join(rest[1:], " ")
|
|
// Strip --yes flag if present
|
|
note = strings.TrimSpace(strings.TrimSuffix(note, "--yes"))
|
|
if len(note) > 200 {
|
|
fmt.Fprintf(os.Stderr, "linkedin invite: note exceeds 200 chars (got %d)\n", len(note))
|
|
return 1
|
|
}
|
|
// Check for --yes flag
|
|
skipConfirm := false
|
|
for _, arg := range rest[1:] {
|
|
if arg == "--yes" {
|
|
skipConfirm = true
|
|
break
|
|
}
|
|
}
|
|
if !promptConfirm(profileURL, note, skipConfirm) {
|
|
fmt.Println("cancelled")
|
|
return 0
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
if err := cli.SendInvite(ctx, profileURL, note); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("sent")
|
|
return nil
|
|
})
|
|
|
|
case "jobs":
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin jobs <keywords> [--location <loc>] [--remote]")
|
|
return 2
|
|
}
|
|
var location string
|
|
remoteOnly := false
|
|
var keywords []string
|
|
for i := 0; i < len(rest); i++ {
|
|
switch rest[i] {
|
|
case "--location":
|
|
if i+1 < len(rest) {
|
|
location = rest[i+1]
|
|
i++
|
|
}
|
|
case "--remote":
|
|
remoteOnly = true
|
|
default:
|
|
keywords = append(keywords, rest[i])
|
|
}
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
results, err := cli.SearchJobs(ctx, strings.Join(keywords, " "), location, remoteOnly)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, j := range results {
|
|
fmt.Printf("%d. %s @ %s\n %s | %s\n\n", i+1, j.Title, j.Company, j.Location, j.URL)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
case "contacts":
|
|
if len(rest) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex linkedin contacts <company>")
|
|
return 2
|
|
}
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
contacts, err := cli.FindContacts(ctx, strings.Join(rest, " "))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, c := range contacts {
|
|
fmt.Printf("%d. %s — %s\n %s\n\n", i+1, c.Name, c.Title, c.ProfileURL)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
case "login":
|
|
runErr = withLinkedInClient(ctx, func(cli *linkedin.Client) error {
|
|
if err := cli.Login(ctx); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("session established — headless commands will work now")
|
|
return nil
|
|
})
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", sub)
|
|
return 2
|
|
}
|
|
|
|
if runErr != nil {
|
|
fmt.Fprintf(os.Stderr, "linkedin %s: %v\n", sub, runErr)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func msgTypeLabel(t linkedin.MessageType) string {
|
|
switch t {
|
|
case linkedin.MsgTypeVague:
|
|
return "vague"
|
|
case linkedin.MsgTypeSpecific:
|
|
return "specific"
|
|
case linkedin.MsgTypeFollowUp:
|
|
return "follow-up"
|
|
case linkedin.MsgTypeSpam:
|
|
return "spam"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// findRepoRoot walks up from cwd looking for internal/scan/adapters.yaml.
|
|
func findRepoRoot() string {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
d := wd
|
|
for i := 0; i < 6; i++ {
|
|
if _, err := os.Stat(filepath.Join(d, "internal", "scan", "adapters.yaml")); err == nil {
|
|
return d
|
|
}
|
|
parent := filepath.Dir(d)
|
|
if parent == d {
|
|
break
|
|
}
|
|
d = parent
|
|
}
|
|
return "."
|
|
}
|
|
|
|
// runCV generates a tailored HTML CV + PDF for a job URL, or a generic CV
|
|
// if no --job-url is given.
|
|
func runCV(args []string) int {
|
|
var jobURL string
|
|
for i := 0; i < len(args); i++ {
|
|
if args[i] == "--job-url" && i+1 < len(args) {
|
|
jobURL = args[i+1]
|
|
i++
|
|
}
|
|
}
|
|
|
|
home, herr := os.UserHomeDir()
|
|
if herr != nil {
|
|
fmt.Fprintf(os.Stderr, "cv: resolve home: %v\n", herr)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cv: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
|
|
exporter := &cv.HTMLExporter{
|
|
DB: sdb.Conn(),
|
|
CVPath: filepath.Join(home, ".apex", "cv.md"),
|
|
OutputDir: filepath.Join(home, ".apex", "cv"),
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
result, err := exporter.Generate(ctx, jobURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "cv: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("HTML: %s\n", result.HTMLPath)
|
|
if result.PDFPath != "" {
|
|
fmt.Printf("PDF: %s\n", result.PDFPath)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runPatterns prints application pattern analysis.
|
|
func runPatterns(args []string) int {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "patterns: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "patterns: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
|
|
stats, err := patterns.Analyze(sdb.Conn())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "patterns: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Print(patterns.Format(stats))
|
|
return 0
|
|
}
|
|
|
|
// runFollowup prints pending follow-ups based on status cadence.
|
|
func runFollowup(args []string) int {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "followup: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "followup: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
|
|
items, err := followup.Pending(sdb.Conn())
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "followup: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Print(followup.Format(items))
|
|
return 0
|
|
}
|
|
|
|
// runStories prints STAR stories extracted from recent evaluation reports.
|
|
func runStories(args []string) int {
|
|
limit := 20
|
|
for i := 0; i < len(args); i++ {
|
|
if args[i] == "--limit" && i+1 < len(args) {
|
|
if v, err := strconv.Atoi(args[i+1]); err == nil {
|
|
limit = v
|
|
i++
|
|
}
|
|
}
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "stories: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "stories: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
rows, err := db.Query(`
|
|
SELECT j.company, j.title, r.stories
|
|
FROM reports r
|
|
JOIN applications a ON a.id = r.application_id
|
|
JOIN jobs j ON j.id = a.job_id
|
|
WHERE r.stories IS NOT NULL AND r.stories != '' AND r.stories != '[]'
|
|
ORDER BY r.generated_at DESC
|
|
LIMIT ?
|
|
`, limit)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "stories: query: %v\n", err)
|
|
return 1
|
|
}
|
|
defer rows.Close()
|
|
|
|
type story struct {
|
|
Title string `json:"title"`
|
|
Situation string `json:"situation"`
|
|
Action string `json:"action"`
|
|
Result string `json:"result"`
|
|
}
|
|
|
|
found := 0
|
|
for rows.Next() {
|
|
var company, title, storiesJSON string
|
|
if err := rows.Scan(&company, &title, &storiesJSON); err != nil {
|
|
continue
|
|
}
|
|
var stories []story
|
|
if err := json.Unmarshal([]byte(storiesJSON), &stories); err != nil || len(stories) == 0 {
|
|
continue
|
|
}
|
|
fmt.Printf("=== %s — %s ===\n\n", company, title)
|
|
for i, s := range stories {
|
|
fmt.Printf("Story %d: %s\n", i+1, s.Title)
|
|
if s.Situation != "" {
|
|
fmt.Printf(" Situation: %s\n", s.Situation)
|
|
}
|
|
if s.Action != "" {
|
|
fmt.Printf(" Action: %s\n", s.Action)
|
|
}
|
|
if s.Result != "" {
|
|
fmt.Printf(" Result: %s\n", s.Result)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
found++
|
|
}
|
|
if found == 0 {
|
|
fmt.Println("No stories found. Run eval on some jobs first.")
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runPipelineAdd queues a job URL for batch evaluation.
|
|
func runPipelineAdd(args []string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex pipeline add <url>")
|
|
return 2
|
|
}
|
|
jobURL := args[0]
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline add: %v\n", err)
|
|
return 1
|
|
}
|
|
sdb, err := store.NewDB(filepath.Join(home, ".apex", "apex.db"))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline add: db: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
// Upsert job row (minimal — URL only; company/title will be filled on eval).
|
|
_, err = db.Exec(`
|
|
INSERT INTO jobs (url, company, title, source)
|
|
VALUES (?, '', '', 'manual')
|
|
ON CONFLICT(url) DO NOTHING
|
|
`, jobURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline add: upsert job: %v\n", err)
|
|
return 1
|
|
}
|
|
var jobID int64
|
|
if err := db.QueryRow(`SELECT id FROM jobs WHERE url = ?`, jobURL).Scan(&jobID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline add: lookup job: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Create application row at Queued status if not already tracked.
|
|
_, err = db.Exec(`
|
|
INSERT INTO applications (job_id, status)
|
|
VALUES (?, 'Queued')
|
|
ON CONFLICT(job_id) DO NOTHING
|
|
`, jobID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline add: upsert application: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Insert into pipeline table.
|
|
_, err = db.Exec(`INSERT INTO pipeline (job_id) VALUES (?)`, jobID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline add: insert pipeline: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("Queued: %s\n", jobURL)
|
|
return 0
|
|
}
|
|
|
|
// runPipelineList prints all jobs currently in the pipeline queue.
|
|
func runPipelineList(args []string) int {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline list: %v\n", err)
|
|
return 1
|
|
}
|
|
sdb, err := store.NewDB(filepath.Join(home, ".apex", "apex.db"))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline list: db: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
rows, err := db.Query(`
|
|
SELECT p.id, COALESCE(j.company,''), COALESCE(j.title,''), j.url, p.queued_at, p.completed_at
|
|
FROM pipeline p
|
|
JOIN jobs j ON p.job_id = j.id
|
|
ORDER BY p.queued_at
|
|
`)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline list: query: %v\n", err)
|
|
return 1
|
|
}
|
|
defer rows.Close()
|
|
|
|
fmt.Printf("%-4s %-20s %-30s %-10s %s\n", "ID", "Company", "Title", "Queued", "Status")
|
|
fmt.Println(strings.Repeat("-", 80))
|
|
count := 0
|
|
for rows.Next() {
|
|
var id int64
|
|
var company, title, jobURL string
|
|
var queuedAt time.Time
|
|
var completedAt sql.NullTime
|
|
if err := rows.Scan(&id, &company, &title, &jobURL, &queuedAt, &completedAt); err != nil {
|
|
continue
|
|
}
|
|
status := "pending"
|
|
if completedAt.Valid {
|
|
status = "done"
|
|
}
|
|
if company == "" {
|
|
company = "(manual)"
|
|
}
|
|
if title == "" {
|
|
title = jobURL
|
|
}
|
|
fmt.Printf("%-4d %-20s %-30s %-10s %s\n",
|
|
id, truncDisplay(company, 20), truncDisplay(title, 30),
|
|
queuedAt.Format("2006-01-02"), status)
|
|
count++
|
|
}
|
|
if count == 0 {
|
|
fmt.Println("Pipeline is empty. Use: apex pipeline add <url>")
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runPipelineRun evaluates all pending pipeline jobs via eval.Batch.
|
|
func runPipelineRun(args []string) int {
|
|
minScore := 0.0
|
|
for i := 0; i < len(args)-1; i++ {
|
|
if args[i] == "--min-score" {
|
|
v, err := strconv.ParseFloat(args[i+1], 64)
|
|
if err == nil {
|
|
minScore = v
|
|
}
|
|
}
|
|
}
|
|
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline run: claude not available: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline run: %v\n", err)
|
|
return 1
|
|
}
|
|
sdb, err := store.NewDB(filepath.Join(home, ".apex", "apex.db"))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline run: db: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
// Load pending pipeline jobs.
|
|
rows, err := db.Query(`
|
|
SELECT p.id, j.url, COALESCE(j.company,''), COALESCE(j.title,''), COALESCE(j.description,''), COALESCE(j.source,'manual')
|
|
FROM pipeline p
|
|
JOIN jobs j ON p.job_id = j.id
|
|
WHERE p.completed_at IS NULL
|
|
ORDER BY p.queued_at
|
|
`)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "pipeline run: query: %v\n", err)
|
|
return 1
|
|
}
|
|
defer rows.Close()
|
|
|
|
type pipelineRow struct {
|
|
pipelineID int64
|
|
job eval.JobContext
|
|
}
|
|
var pending []pipelineRow
|
|
for rows.Next() {
|
|
var pr pipelineRow
|
|
if err := rows.Scan(&pr.pipelineID, &pr.job.URL, &pr.job.Company, &pr.job.Title, &pr.job.JDText, &pr.job.Source); err != nil {
|
|
continue
|
|
}
|
|
pending = append(pending, pr)
|
|
}
|
|
|
|
if len(pending) == 0 {
|
|
fmt.Println("No pending pipeline jobs.")
|
|
return 0
|
|
}
|
|
|
|
fmt.Printf("Running pipeline: %d job(s)\n", len(pending))
|
|
|
|
jobs := make([]eval.JobContext, len(pending))
|
|
for i, pr := range pending {
|
|
jobs[i] = pr.job
|
|
}
|
|
|
|
batch := &eval.Batch{
|
|
Client: client,
|
|
Writer: &eval.ReportWriter{DB: db, ReportsDir: filepath.Join(home, ".apex", "reports")},
|
|
DB: db,
|
|
CVPath: filepath.Join(home, ".apex", "cv.md"),
|
|
Concurrency: eval.DefaultConcurrency,
|
|
MinScore: minScore,
|
|
}
|
|
|
|
// Build URL→pipelineID map for completion tracking.
|
|
pipelineIDs := make(map[string]int64, len(pending))
|
|
for _, pr := range pending {
|
|
pipelineIDs[pr.job.URL] = pr.pipelineID
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
|
defer cancel()
|
|
|
|
for ev := range batch.Run(ctx, jobs) {
|
|
pid := pipelineIDs[ev.Job.URL]
|
|
switch ev.Kind {
|
|
case eval.EventStart:
|
|
fmt.Printf("START\t%s\t%s\n", ev.Job.Company, ev.Job.Title)
|
|
if pid != 0 {
|
|
_, _ = db.Exec(`UPDATE pipeline SET started_at = CURRENT_TIMESTAMP WHERE id = ? AND started_at IS NULL`, pid)
|
|
}
|
|
case eval.EventDone:
|
|
fmt.Printf("DONE\t%s\t%.1f\t%s\n", ev.Job.Company, ev.Eval.Score, ev.Saved.FilePath)
|
|
if pid != 0 {
|
|
_, _ = db.Exec(`UPDATE pipeline SET completed_at = CURRENT_TIMESTAMP WHERE id = ?`, pid)
|
|
}
|
|
case eval.EventSkipped:
|
|
fmt.Printf("SKIP\t%s\t%.1f (below min-score)\n", ev.Job.Company, ev.Eval.Score)
|
|
if pid != 0 {
|
|
_, _ = db.Exec(`UPDATE pipeline SET completed_at = CURRENT_TIMESTAMP WHERE id = ?`, pid)
|
|
}
|
|
case eval.EventError:
|
|
fmt.Printf("ERR\t%s\t%v\n", ev.Job.Company, ev.Err)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runApply marks a job as Applied after interactive user confirmation.
|
|
// Never auto-submits — always requires explicit 'y' from the user.
|
|
func runApply(args []string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex apply <url>")
|
|
return 2
|
|
}
|
|
jobURL := args[0]
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "apply: %v\n", err)
|
|
return 1
|
|
}
|
|
sdb, err := store.NewDB(filepath.Join(home, ".apex", "apex.db"))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "apply: db: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
var company, title, status string
|
|
var score sql.NullFloat64
|
|
err = db.QueryRow(`
|
|
SELECT j.company, j.title, a.status, a.score
|
|
FROM jobs j
|
|
LEFT JOIN applications a ON a.job_id = j.id
|
|
WHERE j.url = ?
|
|
`, jobURL).Scan(&company, &title, &status, &score)
|
|
if err == sql.ErrNoRows {
|
|
fmt.Fprintf(os.Stderr, "apply: job not found: %s\n", jobURL)
|
|
return 1
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "apply: query: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
scoreStr := "unscored"
|
|
if score.Valid {
|
|
scoreStr = fmt.Sprintf("%.1f/5", score.Float64)
|
|
}
|
|
fmt.Printf("%s — %s [%s | score: %s]\n", company, title, status, scoreStr)
|
|
fmt.Fprint(os.Stderr, "Mark as Applied? [y/N]: ")
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
if !scanner.Scan() || strings.ToLower(strings.TrimSpace(scanner.Text())) != "y" {
|
|
fmt.Println("Cancelled.")
|
|
return 0
|
|
}
|
|
|
|
_, err = db.Exec(`
|
|
UPDATE applications
|
|
SET status = 'Applied', updated_at = CURRENT_TIMESTAMP
|
|
WHERE job_id = (SELECT id FROM jobs WHERE url = ?)
|
|
`, jobURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "apply: update: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Printf("Marked as Applied: %s — %s\n", company, title)
|
|
return 0
|
|
}
|
|
|
|
// runCompare loads a job eval and provides gap analysis against senior benchmarks.
|
|
// runEvalMode is the shared implementation for all focused eval modes.
|
|
// It resolves the job, loads its stored report, builds a mode-specific prompt,
|
|
// and streams the Claude response to stdout.
|
|
func runEvalMode(name, promptTemplate string, args []string) int {
|
|
jobURL, err := resolveJobURL(args)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
|
|
return 1
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: resolve home: %v\n", name, err)
|
|
return 1
|
|
}
|
|
sdb, err := store.NewDB(filepath.Join(home, ".apex", "apex.db"))
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: db init: %v\n", name, err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
|
|
job, report, err := loadJobAndReport(sdb.Conn(), jobURL)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
|
|
return 1
|
|
}
|
|
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: claude not available: %v\n", name, err)
|
|
return 1
|
|
}
|
|
|
|
prompt := fmt.Sprintf("%s\n\nJob: %s at %s\n%s", promptTemplate, job.Title, job.Company, report)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
result, err := client.Evaluate(ctx, prompt, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
|
|
return 1
|
|
}
|
|
fmt.Println(result.Raw)
|
|
return 0
|
|
}
|
|
|
|
func runCompare(args []string) int {
|
|
return runEvalMode("compare", "Given this job evaluation, compare it against a senior security engineer benchmark. What gaps exist? What strengths align? Format: Gap Analysis, Strength Map, Net Assessment.", args)
|
|
}
|
|
|
|
func runDeep(args []string) int {
|
|
return runEvalMode("deep", "Perform a deep dive on this job: company financial health signals, team culture indicators from job language, red flags, growth trajectory. Be specific and cite evidence from the JD.", args)
|
|
}
|
|
|
|
func runTraining(args []string) int {
|
|
return runEvalMode("training", "From this job evaluation, extract a personal development roadmap. List: skills to acquire (with timelines), certifications worth pursuing, projects to build, and learning resources.", args)
|
|
}
|
|
|
|
func runProject(args []string) int {
|
|
return runEvalMode("project", "Design 2-3 portfolio projects that would directly address gaps in this job evaluation. For each: title, 1-sentence pitch, tech stack, estimated build time, and why it targets this role.", args)
|
|
}
|
|
|
|
func runNegotiate(args []string) int {
|
|
return runEvalMode("negotiate", "Using this job evaluation score and analysis, build a salary negotiation strategy. Include: target range reasoning, leverage points, BATNA, timing recommendations, and exact opening line.", args)
|
|
}
|
|
|
|
func runContacto(args []string) int {
|
|
return runEvalMode("contacto", "Draft 3 LinkedIn outreach messages to people at this company: one to a hiring manager, one to a peer engineer, one to an internal recruiter. Keep each under 150 words. Be specific to the role.", args)
|
|
}
|
|
|
|
// resolveJobURL returns the job URL from args, or queries the most recent job if empty.
|
|
func resolveJobURL(args []string) (string, error) {
|
|
if len(args) > 0 && args[0] != "" {
|
|
return args[0], nil
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
var url string
|
|
err = db.QueryRow(`SELECT url FROM jobs ORDER BY id DESC LIMIT 1`).Scan(&url)
|
|
if err != nil {
|
|
return "", fmt.Errorf("no recent job found")
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
// loadJobAndReport queries a job by URL and reads its most recent report markdown.
|
|
func loadJobAndReport(db *sql.DB, jobURL string) (eval.JobContext, string, error) {
|
|
var job eval.JobContext
|
|
err := db.QueryRow(`
|
|
SELECT url, company, title, COALESCE(location, ''), COALESCE(source, ''), COALESCE(description, '')
|
|
FROM jobs WHERE url = ?
|
|
`, jobURL).Scan(&job.URL, &job.Company, &job.Title, &job.Location, &job.Source, &job.JDText)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return eval.JobContext{}, "", fmt.Errorf("job not found: %s", jobURL)
|
|
}
|
|
return eval.JobContext{}, "", fmt.Errorf("query job: %w", err)
|
|
}
|
|
|
|
var jobID int64
|
|
err = db.QueryRow(`SELECT id FROM jobs WHERE url = ?`, jobURL).Scan(&jobID)
|
|
if err != nil {
|
|
return eval.JobContext{}, "", fmt.Errorf("lookup job id: %w", err)
|
|
}
|
|
|
|
// Query most recent report for this job
|
|
var filePath string
|
|
err = db.QueryRow(`
|
|
SELECT r.file_path
|
|
FROM reports r
|
|
JOIN applications a ON a.id = r.application_id
|
|
WHERE a.job_id = ?
|
|
ORDER BY r.id DESC
|
|
LIMIT 1
|
|
`, jobID).Scan(&filePath)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return job, "", fmt.Errorf("no evaluation report found for job")
|
|
}
|
|
return eval.JobContext{}, "", fmt.Errorf("query report: %w", err)
|
|
}
|
|
|
|
reportBytes, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return eval.JobContext{}, "", fmt.Errorf("read report: %w", err)
|
|
}
|
|
|
|
return job, string(reportBytes), nil
|
|
}
|
|
|
|
// runIngest parses a DOCX resume file and generates a cv.md with writing style extraction.
|
|
func runIngest(args []string) int {
|
|
if len(args) != 1 {
|
|
fmt.Fprintln(os.Stderr, "usage: apex ingest <file.docx>")
|
|
return 2
|
|
}
|
|
|
|
filePath := args[0]
|
|
|
|
// Validate extension is .docx
|
|
if !strings.HasSuffix(strings.ToLower(filePath), ".docx") {
|
|
fmt.Fprintf(os.Stderr, "ingest: file must have .docx extension, got %q\n", filePath)
|
|
return 1
|
|
}
|
|
|
|
// Parse DOCX
|
|
md, styleProfile, err := docx.Ingest(filePath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ingest: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Determine cv.md path
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ingest: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
cvPath := filepath.Join(home, ".apex", "cv.md")
|
|
|
|
// Check if cv.md already exists
|
|
if _, err := os.Stat(cvPath); err == nil {
|
|
// File exists, ask for confirmation
|
|
fmt.Printf("cv.md already exists at %s. Overwrite? [y/N]: ", cvPath)
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
if !scanner.Scan() || strings.ToLower(strings.TrimSpace(scanner.Text())) != "y" {
|
|
fmt.Println("aborted")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// Check Claude CLI availability
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "ingest: claude not available: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Build Claude prompt
|
|
actionVerbsStr := ""
|
|
if len(styleProfile.ActionVerbs) > 0 {
|
|
verbs := styleProfile.ActionVerbs
|
|
if len(verbs) > 10 {
|
|
verbs = verbs[:10]
|
|
}
|
|
actionVerbsStr = strings.Join(verbs, ", ")
|
|
}
|
|
|
|
prompt := fmt.Sprintf(`Given this resume in plain text, produce a clean, well-structured CV in Markdown format.
|
|
Preserve all dates, company names, job titles, and metrics exactly as written.
|
|
Use ## headings for sections, ### for role titles, bullet points for achievements.
|
|
|
|
Style patterns detected:
|
|
Action verbs: %s
|
|
Summary style: %s
|
|
|
|
Resume text:
|
|
%s`, actionVerbsStr, styleProfile.SummaryStyle, md)
|
|
|
|
// Call Claude
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
result, err := client.Evaluate(ctx, prompt, nil)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "ingest: claude: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Create directory if needed
|
|
if err := os.MkdirAll(filepath.Dir(cvPath), 0755); err != nil {
|
|
fmt.Fprintf(os.Stderr, "ingest: mkdir: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Write CV
|
|
if err := os.WriteFile(cvPath, []byte(result.Raw), 0600); err != nil {
|
|
fmt.Fprintf(os.Stderr, "ingest: write cv: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
fmt.Printf("CV written to %s\n", cvPath)
|
|
if len(styleProfile.ActionVerbs) > 0 {
|
|
topVerbs := styleProfile.ActionVerbs
|
|
if len(topVerbs) > 5 {
|
|
topVerbs = topVerbs[:5]
|
|
}
|
|
fmt.Printf("Detected writing style: %s\n", strings.Join(topVerbs, ", "))
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func truncDisplay(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max-1] + "…"
|
|
}
|
|
|
|
// rawWrapper wraps the raw markdown string with a GetRaw() method
|
|
type rawWrapper struct {
|
|
raw string
|
|
}
|
|
|
|
func (w rawWrapper) GetRaw() string {
|
|
return w.raw
|
|
}
|
|
|
|
// evalClientAdapter wraps eval.Client to implement intake.EvalClient
|
|
type evalClientAdapter struct {
|
|
client *eval.Client
|
|
}
|
|
|
|
func (a *evalClientAdapter) Evaluate(ctx context.Context, prompt string, onChunk interface{}) (intake.EvalResult, error) {
|
|
result, err := a.client.Evaluate(ctx, prompt, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rawWrapper{raw: result.Raw}, nil
|
|
}
|
|
|
|
// runIntake launches an interactive 8-phase intake interview
|
|
func runIntake(args []string) int {
|
|
// Parse flags
|
|
synthesizeOnly := false
|
|
for _, arg := range args {
|
|
if arg == "--synthesize" {
|
|
synthesizeOnly = true
|
|
}
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: resolve home: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
dbPath := filepath.Join(home, ".apex", "apex.db")
|
|
os.MkdirAll(filepath.Dir(dbPath), 0755)
|
|
|
|
sdb, err := store.NewDB(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: db init: %v\n", err)
|
|
return 1
|
|
}
|
|
defer sdb.Close()
|
|
db := sdb.Conn()
|
|
|
|
manager := intake.NewManager(db)
|
|
session, err := manager.GetCurrentSession()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: load session: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// If --synthesize flag is set, skip to synthesis
|
|
if synthesizeOnly {
|
|
if session.IsComplete {
|
|
fmt.Println("Intake already complete. Regenerating cv.md and profile.yml...")
|
|
}
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: claude not available: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
sess := intake.NewSession(db)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
adapter := &evalClientAdapter{client: client}
|
|
if err := sess.Synthesize(ctx, session, adapter); err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: synthesize failed: %v\n", err)
|
|
fmt.Fprintln(os.Stderr, "Run `apex intake --synthesize` to retry without re-answering.")
|
|
return 1
|
|
}
|
|
|
|
fmt.Printf("CV written to %s/.apex/cv.md\n", home)
|
|
fmt.Printf("Profile written to %s/.apex/config/profile.yml\n", home)
|
|
return 0
|
|
}
|
|
|
|
// If already complete, offer to regenerate
|
|
if session.IsComplete {
|
|
fmt.Println("Intake already complete. Use --synthesize to regenerate cv.md and profile.yml.")
|
|
return 0
|
|
}
|
|
|
|
// Interactive flow
|
|
phaseQuestions := map[intake.Phase]string{
|
|
intake.PhaseIdentity: "Full name, email, location (city/country), LinkedIn URL, timezone",
|
|
intake.PhaseEducation: "Degrees, institutions, graduation years, relevant coursework",
|
|
intake.PhaseRoles: "For each job: company, title, dates, key achievements with metrics",
|
|
intake.PhaseCertifications: "Certifications, issuing body, year obtained",
|
|
intake.PhaseSkills: "Technical skills, tools, languages, frameworks",
|
|
intake.PhaseProjects: "Portfolio projects: name, description, tech stack, impact",
|
|
intake.PhasePreferences: "Target roles, salary range (min/max), preferred locations, remote preference",
|
|
intake.PhaseVoice: "Describe your professional philosophy or what makes your work distinctive",
|
|
}
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
|
|
for session.CurrentPhase <= intake.PhaseVoice {
|
|
currentPhase := session.CurrentPhase
|
|
question, ok := phaseQuestions[currentPhase]
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "intake: unknown phase %d\n", currentPhase)
|
|
return 1
|
|
}
|
|
|
|
fmt.Printf("\n[Phase %d of 8] %s\n", int(currentPhase)+1, currentPhase.String())
|
|
fmt.Printf("Question: %s\n", question)
|
|
fmt.Println("(Enter your response, then a blank line to confirm)")
|
|
|
|
// Read multiline input
|
|
var lines []string
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && err.Error() != "EOF" {
|
|
fmt.Fprintf(os.Stderr, "intake: read input: %v\n", err)
|
|
return 1
|
|
}
|
|
line = strings.TrimSuffix(line, "\n")
|
|
if line == "" {
|
|
break
|
|
}
|
|
lines = append(lines, line)
|
|
if err != nil && err.Error() == "EOF" {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(lines) == 0 {
|
|
fmt.Println("Skipping this phase (no input).")
|
|
// Still advance to next phase
|
|
manager.AdvanceToNextPhase(session)
|
|
continue
|
|
}
|
|
|
|
// For now, store as a single fields entry with key "response"
|
|
answer := intake.FormAnswer{
|
|
Phase: currentPhase,
|
|
Fields: map[string]string{"response": strings.Join(lines, "\n")},
|
|
SubmittedAt: time.Now(),
|
|
}
|
|
|
|
if err := manager.SubmitPhaseAnswer(session, answer); err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: save answer: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Advance to next phase
|
|
manager.AdvanceToNextPhase(session)
|
|
}
|
|
|
|
// Mark complete and synthesize
|
|
fmt.Println("\nSynthesizing CV and profile via Claude...")
|
|
if err := manager.CompleteIntake(session); err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: mark complete: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
client := eval.NewClient()
|
|
if err := client.Available(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: claude not available: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
sess := intake.NewSession(db)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// Reload session to ensure we have all answers
|
|
session, err = manager.GetCurrentSession()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: reload session: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
adapter := &evalClientAdapter{client: client}
|
|
if err := sess.Synthesize(ctx, session, adapter); err != nil {
|
|
fmt.Fprintf(os.Stderr, "intake: synthesize failed: %v\n", err)
|
|
fmt.Fprintln(os.Stderr, "Run `apex intake --synthesize` to retry without re-answering.")
|
|
return 1
|
|
}
|
|
|
|
fmt.Printf("CV written to %s/.apex/cv.md\n", home)
|
|
fmt.Printf("Profile written to %s/.apex/config/profile.yml\n", home)
|
|
return 0
|
|
}
|
|
|
|
// promptConfirm prints a confirmation prompt and returns true only if the user enters 'y' (case-insensitive).
|
|
// If yesFlag is true, returns true silently without prompting.
|
|
func promptConfirm(target, body string, yesFlag bool) bool {
|
|
if yesFlag {
|
|
return true
|
|
}
|
|
fmt.Printf("Send to %s:\n\n%s\n\nConfirm? [y/N] ", target, body)
|
|
var confirm string
|
|
fmt.Scanln(&confirm)
|
|
return strings.ToLower(strings.TrimSpace(confirm)) == "y"
|
|
}
|