feat: add skills

This commit is contained in:
Prad Nukala committed 2026-08-26 10:35:34 -04:00
1 parent 6984212b17
commit c50aa92862
150 files changed
+12702

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
---
name: jobsdb-search
version: 1.0.0
description: >
Use this skill to search the local SQLite snapshot of job postings at
documents/postings/jobs.db (~87k postings across ~10k companies, scraped from
Greenhouse/Ashby/Lever/Workday boards, enriched with company sector, funding
stage, crypto-native flag, and a priority tier). Trigger phrases: search the
postings database, jobs.db, local job snapshot, which target companies are
hiring, crypto/fintech roles in New York or San Francisco.
context: fork
enabled: true # set to false to keep this portal installed but have /scrape skip it
allowed-tools: Bash(bun run .agents/skills/jobsdb-search/cli/src/cli.ts *)
---
# jobsdb Search Skill
Searches a **local SQLite snapshot** of job postings with `bun:sqlite`. No network,
no credentials, zero dependencies. The database is user-supplied at
`documents/postings/jobs.db` (override with `JOBSDB_PATH`); it is git-ignored
personal data, not part of the framework.
## ⚠️ This is a static snapshot, not a live board
Rows carry the `scrape_timestamp` they were captured at (currently May 16 to
Jun 1, 2026). Two consequences for `/scrape`:
- **Recency is relative to the snapshot.** `--jobage N` means "posted within N
days of the row's own scrape date". Do **not** apply the calendar 14-day cutoff
to this portal's `date` field; it would drop everything. Treat `meta.snapshot`
as "today" for this source and say so in the results table.
- **Postings may have closed.** Every hit must be confirmed live by fetching its
`url` before it is presented or ranked. A 404 or "job closed" page means skip.
Refresh the snapshot by replacing the `.db` file; the CLI needs no changes.
## Commands
### Search
```bash
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search [flags]
```
- `-q, --query <text>`: FTS5 full-text match on title, company, and location. Tokens are AND-ed; quoting is handled for you.
- `--company <text>`: company name contains text.
- `--location <text>`: location text contains text (`"New York"`, `"San Francisco"`, `Remote`).
- `--country <code>`: exact `location_country` (e.g. `US`).
- `--remote <list>`: comma list of `remote` (global), `remote-regional`, `hybrid`, `onsite`.
- `--seniority <list>`: comma list of `senior`, `staff_principal`, `manager`, `director`, `head_of`, `vp`, `c_level_founder`, `mid_unclassified`, `junior_associate`, `intern`, `unclassified`.
- `--sector <list>`: company `target_sector`: `crypto`, `fintech-nc`, `ai-ml`, `devtools`, `enterprise-saas`, `hardware`, `other`. Most companies are untagged; omit this flag for a wide net.
- `--crypto-native`: only companies flagged crypto-native (~4.4k postings).
- `--tier <n>`: company `priority_tier` at or below n (1 = highest-priority target list).
- `--jobage <days>`: posted within N days of the snapshot (see warning above).
- `--page <n>`, `-n, --limit <n>` (default 20), `--format json|table`.
Unknown flags exit 1 with `{"error":...,"code":"BAD_ARG"}` on stderr.
### Detail
```bash
bun run .agents/skills/jobsdb-search/cli/src/cli.ts detail <posting_id|url> [--format json|plain]
```
The snapshot stores **no description text**. `detail` returns the structured row
(title, company, location, remote class, seniority, salary range when stated,
sector, tier, ATS, resolved date) and the URL; fetch the URL for requirements.
## Usage examples
```bash
# Senior/staff backend roles at crypto-native companies in NYC, snapshot-recent
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search -q "backend engineer" --crypto-native --location "New York" --seniority senior,staff_principal --jobage 30 --format table
# Everything at tier-1 target companies, remote or hybrid
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search --tier 1 --remote remote,hybrid -n 50 --format table
# Founding engineer roles anywhere
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search -q "founding engineer" --format table
# Go / protocol roles
bun run .agents/skills/jobsdb-search/cli/src/cli.ts search -q "protocol engineer" --sector crypto -n 20
```
## Output
Search JSON is `{ "meta": { "count", "page", "total", "snapshot", "db" }, "results": [...] }`.
Each result carries `id`, `title`, `company`, `location`, `date` (ISO, resolved
from ISO or "Posted N Days Ago" text; `date_approximate: true` when the source
said "30+ days"), `url`, `description` (always `null`, see Detail), plus `remote`,
`seniority`, `salary`, `sector`, `crypto_native`, `priority_tier`, `ats`, `snapshot`.
Results are ordered by company priority tier, then newest scrape first.
## Notes
- Workday rows often have no posted date at all; they come back with `date: null` and are dropped by `--jobage`. Search without `--jobage` to include them.
- `location_text` is free text from each ATS ("2 Locations", "US-TX-HOUSTON-011"); `--location` is a substring match, so try the city name without the state.
- The `companies` table also holds funding, headcount growth, GitHub activity, and investor data; this CLI exposes only sector, crypto flag, and tier. Query the `.db` directly with `sqlite3` for anything else.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "jobsdb-cli",
"version": "1.0.0",
"description": "CLI for searching a local SQLite snapshot of job postings (documents/postings/jobs.db). Zero runtime dependencies: bun:sqlite only.",
"type": "module",
"main": "src/cli.ts",
"bin": { "jobsdb-search": "src/cli.ts" },
"scripts": { "start": "bun run src/cli.ts", "typecheck": "tsc --noEmit" },
"dependencies": {},
"devDependencies": { "typescript": "^5.4.0", "@types/bun": "1.3.14" }
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bun
// jobsdb-cli: search a local SQLite snapshot of job postings. Zero dependencies (bun:sqlite).
import { runSearch, type SearchOpts } from "./commands/search.js"
import { runDetail } from "./commands/detail.js"
import { writeError } from "./helpers.js"
const HELP = `jobsdb-cli - search the local postings snapshot (documents/postings/jobs.db)
Usage:
cli.ts search [flags]
cli.ts detail <posting_id|url> [--format json|plain]
Search flags:
-q, --query <text> Full-text match on title, company, location (FTS5)
--company <text> Company name contains <text>
--location <text> Location text contains <text> (e.g. "New York", "San Francisco")
--country <code> location_country equals <code> (e.g. US)
--remote <list> Comma list of: remote, remote-regional, hybrid, onsite
--seniority <list> Comma list of: senior, staff_principal, manager, director, head_of, vp, c_level_founder, mid_unclassified, junior_associate, intern, unclassified
--sector <list> Comma list of company target_sector values (crypto, fintech-nc, ai-ml, devtools, enterprise-saas, hardware, other)
--crypto-native Only companies flagged crypto-native
--tier <n> Company priority_tier <= n (1 is best)
--jobage <days> Posted within N days of the row's scrape snapshot (not today)
--page <n> 1-indexed page. Default 1
-n, --limit <n> Results per page. Default 20
--format json|table Default json
Env:
JOBSDB_PATH Override the database path
`
type Flags = Record<string, string | boolean>
function parse(argv: string[]): { cmd: string; pos: string[]; flags: Flags } {
const [cmd = "", ...rest] = argv
const pos: string[] = []
const flags: Flags = {}
for (let i = 0; i < rest.length; i++) {
const a = rest[i]
if (a === "-q") { flags.query = rest[++i] ?? ""; continue }
if (a === "-n") { flags.limit = rest[++i] ?? ""; continue }
if (a.startsWith("--")) {
const [k, inline] = a.slice(2).split("=", 2)
if (inline !== undefined) { flags[k] = inline; continue }
const next = rest[i + 1]
if (next === undefined || next.startsWith("-")) flags[k] = true
else flags[k] = rest[++i]
continue
}
pos.push(a)
}
return { cmd, pos, flags }
}
function num(flags: Flags, name: string, dflt?: number): number | undefined {
const raw = flags[name]
if (raw === undefined) return dflt
const n = Number(raw)
if (!Number.isFinite(n) || n < 0) { writeError(`--${name} must be a non-negative number, got "${raw}"`, "BAD_ARG"); process.exit(1) }
return n
}
const list = (v: string | boolean | undefined) => (typeof v === "string" && v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined)
const str = (v: string | boolean | undefined) => (typeof v === "string" && v ? v : undefined)
const { cmd, pos, flags } = parse(process.argv.slice(2))
const KNOWN = new Set(["query", "company", "location", "country", "remote", "seniority", "sector", "crypto-native", "tier", "jobage", "page", "limit", "format"])
for (const k of Object.keys(flags)) if (!KNOWN.has(k)) { writeError(`Unknown flag --${k}`, "BAD_ARG"); process.exit(1) }
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") { process.stdout.write(HELP); process.exit(0) }
if (cmd === "search") {
const format = (str(flags.format) ?? "json") as SearchOpts["format"]
if (format !== "json" && format !== "table") { writeError(`--format must be json or table`, "BAD_ARG"); process.exit(1) }
runSearch({
query: str(flags.query), company: str(flags.company), location: str(flags.location), country: str(flags.country),
remote: list(flags.remote), seniority: list(flags.seniority), sector: list(flags.sector),
cryptoNative: flags["crypto-native"] === true, tier: num(flags, "tier"), jobage: num(flags, "jobage"),
page: Math.max(1, num(flags, "page", 1)!), limit: Math.max(1, num(flags, "limit", 20)!), format,
})
} else if (cmd === "detail") {
if (!pos[0]) { writeError("detail requires a <posting_id|url>", "NO_ID"); process.exit(1) }
const format = (str(flags.format) ?? "json") as "json" | "plain"
runDetail(pos[0], format)
} else {
writeError(`Unknown command "${cmd}"`, "BAD_CMD"); process.exit(1)
}
+17
View File
@@ -0,0 +1,17 @@
import { openDb, toJob, writeError, type Row } from "../helpers.js"
export function runDetail(idOrUrl: string, format: "json" | "plain"): void {
const db = openDb()
const row = db
.query("SELECT p.*, c.target_sector, c.crypto_native, c.priority_tier FROM postings p LEFT JOIN companies c ON c.company_id = p.company_id WHERE p.posting_id = ? OR p.url = ? LIMIT 1")
.get(idOrUrl, idOrUrl) as Row | null
if (!row) { writeError(`no posting with id or url ${idOrUrl}`, "NOT_FOUND"); process.exit(1) }
const job = toJob(row)
if (format === "plain") {
process.stdout.write(
[`${job.title} - ${job.company}`, `${job.location} | ${job.remote ?? "?"} | ${job.seniority ?? "?"}`, `Posted ${job.date ?? "?"}${job.date_approximate ? " (approx.)" : ""}, snapshot ${job.snapshot}`, job.salary ? `Salary ${job.salary}` : "", `URL: ${job.url}`, "", "The snapshot stores no description. Fetch the URL above for the full posting text."].filter(Boolean).join("\n") + "\n",
)
return
}
process.stdout.write(JSON.stringify(job, null, 2) + "\n")
}
+72
View File
@@ -0,0 +1,72 @@
import { openDb, toJob, renderTable, writeError, REMOTE_CLASS, type Row } from "../helpers.js"
export interface SearchOpts {
query?: string
company?: string
location?: string
country?: string
remote?: string[]
seniority?: string[]
sector?: string[]
cryptoNative?: boolean
tier?: number
jobage?: number
page: number
limit: number
format: "json" | "table"
}
function ftsMatch(q: string): string {
// Quote every token so user text can never be parsed as FTS5 syntax.
return q.split(/\s+/).filter(Boolean).map((t) => `"${t.replace(/"/g, '""')}"`).join(" ")
}
export function runSearch(opts: SearchOpts): void {
const db = openDb()
const where: string[] = []
const params: (string | number)[] = []
let from = "postings p LEFT JOIN companies c ON c.company_id = p.company_id"
if (opts.query) {
from = "postings_fts f JOIN postings p ON p.rowid = f.rowid LEFT JOIN companies c ON c.company_id = p.company_id"
where.push("postings_fts MATCH ?")
params.push(ftsMatch(opts.query))
}
if (opts.company) { where.push("p.company_name LIKE ?"); params.push(`%${opts.company}%`) }
if (opts.location) { where.push("p.location_text LIKE ?"); params.push(`%${opts.location}%`) }
if (opts.country) { where.push("p.location_country = ?"); params.push(opts.country) }
if (opts.remote?.length) {
const classes = opts.remote.map((r) => REMOTE_CLASS[r.toLowerCase()]).filter(Boolean)
if (classes.length !== opts.remote.length) {
writeError(`--remote accepts ${Object.keys(REMOTE_CLASS).join(", ")}`, "BAD_ARG"); process.exit(1)
}
where.push(`p.location_class IN (${classes.map(() => "?").join(",")})`); params.push(...classes)
}
if (opts.seniority?.length) { where.push(`p.seniority IN (${opts.seniority.map(() => "?").join(",")})`); params.push(...opts.seniority) }
if (opts.sector?.length) { where.push(`c.target_sector IN (${opts.sector.map(() => "?").join(",")})`); params.push(...opts.sector) }
if (opts.cryptoNative) where.push("c.crypto_native = 'True'")
if (opts.tier !== undefined) { where.push("c.priority_tier <= ?"); params.push(opts.tier) }
const sql = `SELECT p.*, c.target_sector, c.crypto_native, c.priority_tier FROM ${from}
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY COALESCE(c.priority_tier, 9), p.scrape_timestamp DESC`
let rows: Row[]
try { rows = db.query(sql).all(...params) as Row[] } catch (e) {
writeError(`query failed: ${(e as Error).message}`, "QUERY_FAILED"); process.exit(1)
}
let jobs = rows.map(toJob)
if (opts.jobage !== undefined) {
// Recency relative to each row's own scrape snapshot, not today's calendar: the DB is a static export.
jobs = jobs.filter((j) => {
if (!j.date || !j.snapshot) return false
const age = (Date.parse(j.snapshot) - Date.parse(j.date)) / 86_400_000
return age <= opts.jobage!
})
}
const total = jobs.length
const start = (opts.page - 1) * opts.limit
const pageJobs = jobs.slice(start, start + opts.limit)
const snapshot = pageJobs.reduce<string | null>((m, j) => (j.snapshot && (!m || j.snapshot > m) ? j.snapshot : m), null)
if (opts.format === "table") { process.stdout.write(renderTable(pageJobs) + "\n"); return }
process.stdout.write(JSON.stringify({ meta: { count: pageJobs.length, page: opts.page, total, snapshot, db: process.env.JOBSDB_PATH ?? "documents/postings/jobs.db" }, results: pageJobs }, null, 2) + "\n")
}
+116
View File
@@ -0,0 +1,116 @@
import { Database } from "bun:sqlite"
import { existsSync } from "node:fs"
export const DB_PATH = process.env.JOBSDB_PATH ?? "documents/postings/jobs.db"
export const REMOTE_CLASS: Record<string, string> = { remote: "R1", "remote-global": "R1", "remote-regional": "R2", hybrid: "H", onsite: "O" }
export const CLASS_LABEL: Record<string, string> = { R1: "Remote (global)", R2: "Remote (regional)", H: "Hybrid", O: "Onsite" }
export interface Row {
posting_id: string
company_id: string | null
company_name: string | null
title: string | null
seniority: string | null
posted_date: string | null
location_text: string | null
location_class: string | null
location_country: string | null
salary_min: string | null
salary_max: string | null
salary_currency: string | null
url: string | null
source_ats: string | null
scrape_timestamp: string | null
target_sector: string | null
crypto_native: string | null
priority_tier: number | null
}
export interface Job {
id: string
title: string | null
company: string | null
location: string | null
date: string | null
date_approximate: boolean
url: string | null
description: null
remote: string | null
seniority: string | null
salary: string | null
sector: string | null
crypto_native: boolean
priority_tier: number | null
ats: string | null
snapshot: string | null
}
export function openDb(): Database {
if (!existsSync(DB_PATH)) {
writeError(`database not found at ${DB_PATH} (set JOBSDB_PATH)`, "NO_DB")
process.exit(1)
}
return new Database(DB_PATH, { readonly: true })
}
/** "Posted 8 Days Ago" + scrape_timestamp -> ISO date. "30+" is a floor, flagged approximate. */
export function resolveDate(posted: string | null, scraped: string | null): { date: string | null; approximate: boolean } {
if (!posted) return { date: null, approximate: false }
// greenhouse/ashby/lever store ISO timestamps; workday stores relative text.
if (/^\d{4}-\d{2}-\d{2}/.test(posted)) return { date: posted.slice(0, 10), approximate: false }
if (!scraped) return { date: null, approximate: false }
const base = new Date(scraped)
if (isNaN(base.getTime())) return { date: null, approximate: false }
const m = /(\d+)(\+?)\s*Days?/i.exec(posted)
let days: number
let approximate = false
if (/Today/i.test(posted)) days = 0
else if (/Yesterday/i.test(posted)) days = 1
else if (m) { days = Number(m[1]); approximate = m[2] === "+" }
else return { date: null, approximate: false }
base.setUTCDate(base.getUTCDate() - days)
return { date: base.toISOString().slice(0, 10), approximate }
}
export function toJob(r: Row): Job {
const { date, approximate } = resolveDate(r.posted_date, r.scrape_timestamp)
const salary = r.salary_min && r.salary_max ? `${r.salary_min}-${r.salary_max} ${r.salary_currency ?? ""}`.trim() : null
return {
id: r.posting_id,
title: r.title || null,
company: r.company_name || null,
location: r.location_text || null,
date,
date_approximate: approximate,
url: r.url || null,
description: null,
remote: r.location_class ? CLASS_LABEL[r.location_class] ?? null : null,
seniority: r.seniority || null,
salary,
sector: r.target_sector || null,
crypto_native: r.crypto_native === "True",
priority_tier: r.priority_tier ?? null,
ats: r.source_ats || null,
snapshot: r.scrape_timestamp ? r.scrape_timestamp.slice(0, 10) : null,
}
}
export function writeError(error: string, code: string): void {
process.stderr.write(JSON.stringify({ error, code }) + "\n")
}
export function renderTable(jobs: Job[]): string {
if (jobs.length === 0) return "No results."
const rows = jobs.map((j) =>
[
(j.title ?? "").slice(0, 44).padEnd(44),
(j.company ?? "-").slice(0, 22).padEnd(22),
(j.location ?? "-").slice(0, 22).padEnd(22),
(j.remote ?? "-").slice(0, 16).padEnd(16),
j.date ? j.date + (j.date_approximate ? "~" : " ") : "-".padEnd(11),
j.url ?? "",
].join(" "),
)
return rows.join("\n")
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}