fix: restore Danish CLI commands/ and tsconfig files dropped by old .gitignore (#53)

The pre-#21 .gitignore's unanchored 'commands/' rule silently excluded
.agents/skills/*/cli/src/commands/ (and the tsconfigs) from the initial
release, so every clone's four Danish portal CLIs failed on import with
'Cannot find module ./commands/search.js'. #21 fixed the rule but the
files were never restored - git history has no trace of them.

Restored from the maintainer's working copies, including the updated
jobindex helpers.ts (Jobindex moved search results from the JSON
endpoint, which now returns 204, into an embedded HTML Stash blob).

Verified: all four CLIs typecheck and return live results with their
documented flags. Surfaced while reviewing #52.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mads Lorentzen
2026-07-07 17:39:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fce2cf23c0
commit f3d4448cca
18 changed files with 2085 additions and 0 deletions
@@ -0,0 +1,164 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { fetchWithUA, writeError, BASE_URL } from "../helpers.js"
import { parse as parseHtml } from "node-html-parser"
export const detail = defineCommand({
name: "detail",
description: "Full detail for a single job posting",
options: {
format: option(z.enum(["json", "plain"]).default("json"), {
description: "Output format: json, plain",
}),
},
handler: async ({ positional, flags, signal }) => {
if (signal.aborted) return
const id = positional[0]
if (!id) {
writeError("Job ID is required", "MISSING_REQUIRED")
process.exit(1)
}
const url = `${BASE_URL}/job/${id}/`
try {
const response = await fetchWithUA(url)
if (response.status === 404) {
writeError("Job not found", "NOT_FOUND")
process.exit(1)
}
if (!response.ok) {
writeError(`Failed to fetch job page: ${response.status} ${response.statusText}`, "API_ERROR")
process.exit(1)
}
const html = await response.text()
if (signal.aborted) return
const root = parseHtml(html)
// Find all <script type="application/ld+json"> tags
const scripts = root.querySelectorAll('script[type="application/ld+json"]')
let jobPosting: Record<string, unknown> | null = null
for (const script of scripts) {
try {
const json = JSON.parse(script.text)
if (json["@type"] === "JobPosting") {
jobPosting = json
break
}
// Could be an array
if (Array.isArray(json)) {
const found = json.find((item) => item["@type"] === "JobPosting")
if (found) {
jobPosting = found
break
}
}
} catch {
// not valid JSON — skip
}
}
if (!jobPosting) {
writeError("No JSON-LD found on job page", "PARSE_ERROR")
process.exit(1)
}
// Extract fields
const identifier = jobPosting["identifier"] as Record<string, unknown> | undefined
const jobId = identifier?.["value"] ? String(identifier["value"]) : id
// Check if the returned job ID doesn't match what was requested — indicates not found / redirect to different job
// We skip this check since short URL redirects to the actual job and returns 200
const hiringOrg = jobPosting["hiringOrganization"] as Record<string, unknown> | undefined
const jobLocation = jobPosting["jobLocation"] as Record<string, unknown> | undefined
const address = (jobLocation?.["address"] as Record<string, unknown>) ?? {}
const employmentType = jobPosting["employmentType"]
const empTypeArr: string[] = Array.isArray(employmentType)
? employmentType.map(String)
: employmentType
? [String(employmentType)]
: []
const validThrough = jobPosting["validThrough"]
let deadline: string | null = null
if (validThrough && String(validThrough).length > 0) {
// Normalize to YYYY-MM-DD if it's an ISO datetime
const dtStr = String(validThrough)
deadline = dtStr.substring(0, 10) // take first 10 chars = YYYY-MM-DD
if (deadline === "0001-01-01") deadline = null // invalid date
}
const output = {
id: jobId,
url: String(jobPosting["url"] ?? url),
title: String(jobPosting["title"] ?? ""),
description: String(jobPosting["description"] ?? ""),
datePosted: String(jobPosting["datePosted"] ?? ""),
deadline,
employmentType: empTypeArr,
company: {
name: String(hiringOrg?.["name"] ?? ""),
logo: hiringOrg?.["logo"] ? String(hiringOrg["logo"]) : null,
},
location: {
streetAddress: String(address["streetAddress"] ?? ""),
city: String(address["addressLocality"] ?? ""),
postalCode: String(address["postalCode"] ?? ""),
country: String(address["addressCountry"] ?? ""),
},
}
// Verify the job ID matches if possible — if the page 404'd or redirected to a different job
// For invalid IDs that redirect to a generic page, the JSON-LD may be absent
// We already handle the "No JSON-LD" case above
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else {
outputPlain(output)
}
} catch (err) {
if (err instanceof Error && err.message.includes("NOT_FOUND")) {
writeError("Job not found", "NOT_FOUND")
} else {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
}
process.exit(1)
}
},
})
function outputPlain(data: {
id: string
url: string
title: string
description: string
datePosted: string
deadline: string | null
employmentType: string[]
company: { name: string; logo: string | null }
location: { streetAddress: string; city: string; postalCode: string; country: string }
}): void {
console.log(`id: ${data.id}`)
console.log(`title: ${data.title}`)
console.log(`company: ${data.company.name}`)
if (data.company.logo) console.log(`logo: ${data.company.logo}`)
console.log(`location: ${[data.location.streetAddress, data.location.city, data.location.country].filter(Boolean).join(", ")}`)
console.log(`datePosted: ${data.datePosted}`)
console.log(`deadline: ${data.deadline ?? "none"}`)
console.log(`employmentType: ${data.employmentType.join(", ")}`)
console.log(`url: ${data.url}`)
console.log("")
// Strip HTML tags for plain description
const plainDescription = data.description.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
console.log(plainDescription)
}
@@ -0,0 +1,199 @@
import { defineCommand, option } from "@bunli/core"
import { z } from "zod"
import { rssFetch, fetchWithUA, writeError, parseRssDescription, extractJobIdFromUrl, BASE_URL } from "../helpers.js"
export const search = defineCommand({
name: "search",
description: "Search job listings via RSS feed",
options: {
key: option(z.string().optional(), {
description: "Keyword search (title, company, keyword)",
}),
exclude: option(z.string().optional(), {
description: "Exclude keywords (antikey)",
}),
type: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Job type code (cvtype). Repeatable: --type 3 --type 6",
}),
education: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Education field code (udd). Repeatable.",
}),
location: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Region code (amt). Repeatable.",
}),
"work-area": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Work area / function code (erf). Repeatable.",
}),
industry: option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Industry code (branche). Repeatable.",
}),
"suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Suitable-for code (andet). Repeatable.",
}),
company: option(z.coerce.number().optional(), {
description: "Company ID (virk)",
}),
remote: option(z.string().optional(), {
description: "Remote work: helt or delvist (fjernarbejde)",
}),
since: option(z.string().optional(), {
description: "Posted on or after date, format YYYY-MM-DD (oprettet)",
}),
limit: option(z.coerce.number().optional(), {
description: "Cap total results returned by CLI (client-side)",
}),
format: option(z.enum(["json", "table", "plain"]).default("json"), {
description: "Output format: json, table, plain",
}),
},
handler: async ({ flags, signal }) => {
if (signal.aborted) return
// Require at least one filter
const hasFilter =
flags.key ||
flags.exclude ||
flags.type ||
flags.education ||
flags.location ||
flags["work-area"] ||
flags.industry ||
flags["suitable-for"] ||
flags.company !== undefined ||
flags.remote ||
flags.since
if (!hasFilter) {
writeError("--key or at least one filter is required", "MISSING_REQUIRED")
process.exit(1)
}
const params: Record<string, string | string[]> = {}
if (flags.key) params["key"] = flags.key
if (flags.exclude) params["antikey"] = flags.exclude
if (flags.type) {
const vals = Array.isArray(flags.type) ? flags.type : [flags.type]
params["cvtype"] = vals.flatMap((v) => v.split(","))
}
if (flags.education) {
const vals = Array.isArray(flags.education) ? flags.education : [flags.education]
params["udd"] = vals.flatMap((v) => v.split(","))
}
if (flags.location) {
const vals = Array.isArray(flags.location) ? flags.location : [flags.location]
params["amt"] = vals.flatMap((v) => v.split(","))
}
if (flags["work-area"]) {
const vals = Array.isArray(flags["work-area"]) ? flags["work-area"] : [flags["work-area"]]
params["erf"] = vals.flatMap((v) => v.split(","))
}
if (flags.industry) {
const vals = Array.isArray(flags.industry) ? flags.industry : [flags.industry]
params["branche"] = vals.flatMap((v) => v.split(","))
}
if (flags["suitable-for"]) {
const vals = Array.isArray(flags["suitable-for"]) ? flags["suitable-for"] : [flags["suitable-for"]]
params["andet"] = vals.flatMap((v) => v.split(","))
}
if (flags.company !== undefined) params["virk"] = String(flags.company)
if (flags.remote) params["fjernarbejde"] = flags.remote
if (flags.since) params["oprettet"] = flags.since
try {
// Fetch RSS feed
const items = await rssFetch(params)
if (signal.aborted) return
// Also fetch total count from HTML page (secondary request)
let total: number | null = null
try {
// Small delay to be polite
await new Promise((resolve) => setTimeout(resolve, 300))
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
for (const v of value) searchParams.append(key, v)
} else {
searchParams.append(key, value)
}
}
const htmlUrl = `${BASE_URL}/job/?${searchParams.toString()}`
const htmlResp = await fetchWithUA(htmlUrl)
if (htmlResp.ok) {
const html = await htmlResp.text()
// Extract from <title> tag: "457 relevante job og karriereopslag i Akademikernes Jobbank"
const titleMatch = html.match(/<title[^>]*>\s*(\d[\d.,]*)\s+relevante job/i)
if (titleMatch) {
total = parseInt(titleMatch[1].replace(/[.,]/g, ""), 10)
}
}
} catch {
// Secondary request failed — total stays null
}
// Normalize items
let results = items.map((item) => {
const parsed = parseRssDescription(item.description)
const id = extractJobIdFromUrl(item.link)
const posted = item.pubDate ? new Date(item.pubDate).toISOString() : ""
return {
id,
title: item.title,
company: parsed.company,
location: parsed.location,
jobType: parsed.jobType,
description: item.description,
url: item.link,
posted,
deadline: parsed.deadline,
}
})
// Apply limit
if (flags.limit !== undefined) {
results = results.slice(0, flags.limit)
}
const output = { meta: { total }, results }
if (flags.format === "json") {
console.log(JSON.stringify(output, null, 2))
} else if (flags.format === "table") {
outputTable(results)
} else {
outputPlain(results)
}
} catch (err) {
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
process.exit(1)
}
},
})
function outputTable(results: Array<Record<string, unknown>>): void {
console.log("id title company location deadline")
for (const r of results) {
const id = String(r.id ?? "-").padEnd(9)
const title = String(r.title ?? "-").substring(0, 36).padEnd(36)
const company = String(r.company ?? "-").substring(0, 22).padEnd(22)
const location = String(r.location ?? "-").substring(0, 18).padEnd(18)
const deadline = String(r.deadline ?? "-")
console.log(`${id} ${title} ${company} ${location} ${deadline}`)
}
}
function outputPlain(results: Array<Record<string, unknown>>): void {
for (const r of results) {
console.log(`id: ${r.id}`)
console.log(`title: ${r.title}`)
console.log(`company: ${r.company}`)
console.log(`location: ${r.location}`)
console.log(`jobType: ${r.jobType}`)
console.log(`posted: ${r.posted}`)
console.log(`deadline: ${r.deadline ?? "none"}`)
console.log(`url: ${r.url}`)
console.log("")
}
}
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}