Files
apex-public/cmd/apex/cli.go
T
2026-07-06 11:05:50 -04:00

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"
}