mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
co-authored by
Claude Fable 5
parent
fce2cf23c0
commit
f3d4448cca
@@ -0,0 +1,132 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError, stripHtml } from "../helpers.js"
|
||||
|
||||
interface DetailApiResponse {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
publicationDateTime: string
|
||||
unpublicationDateTime: string | null
|
||||
approvalStatus: string
|
||||
views: number
|
||||
createdDateTime: string
|
||||
updatedDateTime: string
|
||||
isAnonymousEmployer: boolean
|
||||
hasLogo: boolean
|
||||
logoUrl: string | null
|
||||
employer: {
|
||||
cvrNumber: string | null
|
||||
pNumber: string | null
|
||||
name: string
|
||||
hasCompanyLogo: boolean
|
||||
}
|
||||
job: {
|
||||
type: string
|
||||
address: {
|
||||
streetName: string | null
|
||||
city: string | null
|
||||
postalCode: string | null
|
||||
municipality: string | null
|
||||
countryCode: string
|
||||
countryName: string
|
||||
}
|
||||
noFixedWorkplace: boolean
|
||||
isLimitedPeriod: boolean
|
||||
isDisabilityFriendly: boolean
|
||||
isPartTime: boolean
|
||||
employmentDate: string | null
|
||||
conceptUriDa: string | null
|
||||
preferredLabelDa: string | null
|
||||
driversLicenses: unknown[]
|
||||
classifications: unknown[]
|
||||
shifts: unknown[]
|
||||
isFavorite: boolean
|
||||
}
|
||||
application: {
|
||||
deadlineDate: string | null
|
||||
availablePositions: number
|
||||
contactPersons: Array<{
|
||||
firstNames: string | null
|
||||
lastName: string | null
|
||||
phoneNumber: string | null
|
||||
}>
|
||||
url: string | null
|
||||
urlText: string | null
|
||||
isApplicationDeadlineASAP: boolean
|
||||
}
|
||||
organisationTypeId: number | null
|
||||
user: string | null
|
||||
}
|
||||
|
||||
export const detail = defineCommand({
|
||||
name: "detail",
|
||||
description: "Full detail for a single job ad",
|
||||
options: {
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ positional, flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
const id = positional[0] as string | undefined
|
||||
if (!id) {
|
||||
writeError("Job ad ID is required", "MISSING_REQUIRED")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch<DetailApiResponse>(
|
||||
`/FindJob/JobAdDetails/${id}`,
|
||||
{ incrementViews: "false" }
|
||||
)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
} else if (flags.format === "table") {
|
||||
outputTable(data)
|
||||
} else {
|
||||
outputPlain(data)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes("404") || message.includes("Not Found")) {
|
||||
writeError("Job ad not found", "NOT_FOUND")
|
||||
} else {
|
||||
writeError(message, "API_ERROR")
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function outputTable(data: DetailApiResponse): void {
|
||||
console.log(`ID: ${data.id}`)
|
||||
console.log(`Title: ${data.title}`)
|
||||
console.log(`Employer: ${data.employer.name}`)
|
||||
console.log(`Type: ${data.job.type}`)
|
||||
console.log(`City: ${data.job.address.city ?? "-"}`)
|
||||
console.log(`Postal: ${data.job.address.postalCode ?? "-"}`)
|
||||
console.log(`Country: ${data.job.address.countryName}`)
|
||||
console.log(`Published: ${data.publicationDateTime}`)
|
||||
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
|
||||
console.log(`Positions: ${data.application.availablePositions}`)
|
||||
console.log(`Apply URL: ${data.application.url ?? "-"}`)
|
||||
}
|
||||
|
||||
function outputPlain(data: DetailApiResponse): void {
|
||||
console.log(`Title: ${data.title}`)
|
||||
console.log(`Employer: ${data.employer.name}`)
|
||||
console.log(`Location: ${data.job.address.city ?? "-"}, ${data.job.address.countryName}`)
|
||||
console.log(`Published: ${data.publicationDateTime}`)
|
||||
console.log(`Deadline: ${data.application.deadlineDate ?? "-"}`)
|
||||
console.log(`Positions: ${data.application.availablePositions}`)
|
||||
if (data.application.url) {
|
||||
console.log(`Apply: ${data.application.url}`)
|
||||
}
|
||||
console.log("")
|
||||
console.log(stripHtml(data.body))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError } from "../helpers.js"
|
||||
|
||||
interface OccupationAlias {
|
||||
aliasIdentifier: string
|
||||
conceptUriDa: string
|
||||
alternativeLabelDa: string
|
||||
}
|
||||
|
||||
interface Occupation {
|
||||
conceptUriDa: string
|
||||
preferredLabelDa: string
|
||||
aliases: OccupationAlias[]
|
||||
}
|
||||
|
||||
export const occupations = defineCommand({
|
||||
name: "occupations",
|
||||
description: "Search occupation types (for building filters)",
|
||||
options: {
|
||||
"search-string": option(z.string().optional(), {
|
||||
description: "Search term for occupation, e.g. sygeplejerske",
|
||||
}),
|
||||
"per-page": option(z.coerce.number().default(10), {
|
||||
description: "Max results to return",
|
||||
}),
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
if (!flags["search-string"]) {
|
||||
writeError("--search-string is required", "MISSING_REQUIRED")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
searchString: flags["search-string"],
|
||||
pageSize: String(flags["per-page"]),
|
||||
}
|
||||
|
||||
try {
|
||||
const rawData = await apiFetch<Occupation[]>("/OccupationSearch", params)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
// Client-side filter — API does not enforce pageSize reliably
|
||||
const data = rawData.slice(0, flags["per-page"])
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(data, null, 2))
|
||||
} else if (flags.format === "table") {
|
||||
outputTable(data)
|
||||
} else {
|
||||
outputPlain(data)
|
||||
}
|
||||
} catch (err) {
|
||||
writeError(err instanceof Error ? err.message : String(err), "API_ERROR")
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function outputTable(data: Occupation[]): void {
|
||||
console.log("conceptUriDa preferredLabelDa")
|
||||
for (const o of data) {
|
||||
const uri = o.conceptUriDa.substring(0, 70).padEnd(70)
|
||||
const label = o.preferredLabelDa
|
||||
console.log(`${uri} ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
function outputPlain(data: Occupation[]): void {
|
||||
for (const o of data) {
|
||||
console.log(`label: ${o.preferredLabelDa}`)
|
||||
console.log(`uri: ${o.conceptUriDa}`)
|
||||
if (o.aliases.length > 0) {
|
||||
console.log(`aliases: ${o.aliases.map((a) => a.alternativeLabelDa).join(", ")}`)
|
||||
}
|
||||
console.log("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError } from "../helpers.js"
|
||||
|
||||
interface SearchApiResponse {
|
||||
jobAds: JobAdRaw[]
|
||||
searchFacets: SearchFacetsRaw
|
||||
totalJobAdCount: number
|
||||
searchString: string | null
|
||||
}
|
||||
|
||||
interface JobAdRaw {
|
||||
jobAdId: string
|
||||
title: string
|
||||
hiringOrgName: string
|
||||
occupation: string | null
|
||||
municipality: string | null
|
||||
postalCode: number | null
|
||||
postalDistrictName: string | null
|
||||
country: string
|
||||
publicationDate: string
|
||||
applicationDeadline: string | null
|
||||
applicationDeadlineStatus: string | null
|
||||
workHourPartTime: boolean
|
||||
isExternal: boolean
|
||||
hasLogo: boolean
|
||||
logoUrl: string | null
|
||||
cvr: string | null
|
||||
workPlaceAddress: string
|
||||
conceptUriDa?: string | null
|
||||
isSeen: boolean
|
||||
isFavorite: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface SearchFacetsRaw {
|
||||
regions: Array<{ type: string; jobAdCount: number }>
|
||||
workHours: Array<{ type: string; jobAdCount: number }>
|
||||
employmentDurations: Array<{ type: string; jobAdCount: number }>
|
||||
occupationAreas: Array<{ identifier: string; jobAdCount: number }>
|
||||
countries: Array<{ label: string; identifier: string; jobAdCount: number }>
|
||||
}
|
||||
|
||||
export const search = defineCommand({
|
||||
name: "search",
|
||||
description: "Search for job ads with filters",
|
||||
options: {
|
||||
"search-string": option(z.string().optional(), {
|
||||
description: "Free-text keyword search (job title, skills, employer)",
|
||||
}),
|
||||
page: option(z.coerce.number().default(1), {
|
||||
description: "Page number (1-indexed)",
|
||||
}),
|
||||
"per-page": option(z.coerce.number().default(10), {
|
||||
description: "Results per page",
|
||||
}),
|
||||
order: option(z.string().default("PublicationDate"), {
|
||||
description: "Sort order: PublicationDate, BestMatch, ApplicationDate",
|
||||
}),
|
||||
region: option(z.string().optional(), {
|
||||
description: "One region value",
|
||||
}),
|
||||
"work-hours": option(z.string().optional(), {
|
||||
description: "FullTime or PartTime",
|
||||
}),
|
||||
duration: option(z.string().optional(), {
|
||||
description: "Permanent or Temporary",
|
||||
}),
|
||||
"job-type": option(z.string().optional(), {
|
||||
description: "Announcement type: Ordinaert, Efterloenner, Foertidspension",
|
||||
}),
|
||||
"postal-code": option(z.string().optional(), {
|
||||
description: "Postal code for radius search",
|
||||
}),
|
||||
radius: option(z.coerce.number().default(50), {
|
||||
description: "Radius in km from postal code",
|
||||
}),
|
||||
"occupation-area": option(z.string().optional(), {
|
||||
description: "Occupation area identifier, e.g. 10000",
|
||||
}),
|
||||
"occupation-group": option(z.string().optional(), {
|
||||
description: "Occupation group identifier, e.g. 10060",
|
||||
}),
|
||||
limit: option(z.coerce.number().optional(), {
|
||||
description: "Cap total results returned by CLI",
|
||||
}),
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
const params: Record<string, string> = {
|
||||
resultsPerPage: String(flags["per-page"]),
|
||||
pageNumber: String(flags.page),
|
||||
orderType: flags.order,
|
||||
}
|
||||
|
||||
if (flags["search-string"]) params["searchString"] = flags["search-string"]
|
||||
if (flags.region) params["regions"] = flags.region
|
||||
if (flags["work-hours"]) params["workHoursType"] = flags["work-hours"]
|
||||
if (flags.duration) params["employmentDurationType"] = flags.duration
|
||||
if (flags["job-type"]) params["jobAnnouncementType"] = flags["job-type"]
|
||||
if (flags["postal-code"]) {
|
||||
params["postalCode"] = flags["postal-code"]
|
||||
params["kmRadius"] = String(flags.radius)
|
||||
}
|
||||
if (flags["occupation-area"]) params["occupationAreas"] = flags["occupation-area"]
|
||||
if (flags["occupation-group"]) params["occupationGroups"] = flags["occupation-group"]
|
||||
|
||||
try {
|
||||
const data = await apiFetch<SearchApiResponse>("/FindJob/Search", params)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
// Map raw job ads to documented output shape (omit description)
|
||||
let results = data.jobAds.map((job) => ({
|
||||
jobAdId: job.jobAdId,
|
||||
title: job.title,
|
||||
hiringOrgName: job.hiringOrgName,
|
||||
occupation: job.occupation ?? null,
|
||||
municipality: job.municipality ?? null,
|
||||
postalCode: job.postalCode ?? null,
|
||||
postalDistrictName: job.postalDistrictName ?? null,
|
||||
country: job.country,
|
||||
publicationDate: job.publicationDate,
|
||||
applicationDeadline: job.applicationDeadline ?? null,
|
||||
applicationDeadlineStatus: job.applicationDeadlineStatus ?? null,
|
||||
workHourPartTime: job.workHourPartTime,
|
||||
isExternal: job.isExternal,
|
||||
hasLogo: job.hasLogo,
|
||||
logoUrl: job.logoUrl ?? null,
|
||||
cvr: job.cvr ?? null,
|
||||
workPlaceAddress: job.workPlaceAddress ?? "",
|
||||
isSeen: job.isSeen,
|
||||
isFavorite: job.isFavorite,
|
||||
}))
|
||||
|
||||
if (flags.limit !== undefined) {
|
||||
results = results.slice(0, flags.limit)
|
||||
}
|
||||
|
||||
const facets = {
|
||||
regions: data.searchFacets.regions ?? [],
|
||||
workHours: data.searchFacets.workHours ?? [],
|
||||
employmentDurations: data.searchFacets.employmentDurations ?? [],
|
||||
occupationAreas: data.searchFacets.occupationAreas ?? [],
|
||||
countries: data.searchFacets.countries ?? [],
|
||||
}
|
||||
|
||||
const meta = {
|
||||
totalJobAdCount: data.totalJobAdCount,
|
||||
pageNumber: flags.page,
|
||||
resultsPerPage: flags["per-page"],
|
||||
searchString: data.searchString ?? null,
|
||||
}
|
||||
|
||||
const output = { meta, facets, 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type JobAdResult = {
|
||||
jobAdId: string
|
||||
title: string
|
||||
hiringOrgName: string
|
||||
occupation: string | null
|
||||
municipality: string | null
|
||||
postalCode: number | null
|
||||
publicationDate: string
|
||||
applicationDeadline: string | null
|
||||
}
|
||||
|
||||
function outputTable(results: JobAdResult[]): void {
|
||||
console.log("jobAdId title employer municipality")
|
||||
for (const r of results) {
|
||||
const id = r.jobAdId.padEnd(36)
|
||||
const title = (r.title ?? "-").substring(0, 36).padEnd(36)
|
||||
const employer = (r.hiringOrgName ?? "-").substring(0, 26).padEnd(26)
|
||||
const municipality = String(r.municipality ?? "-")
|
||||
console.log(`${id} ${title} ${employer} ${municipality}`)
|
||||
}
|
||||
}
|
||||
|
||||
function outputPlain(results: JobAdResult[]): void {
|
||||
for (const r of results) {
|
||||
console.log(`id: ${r.jobAdId}`)
|
||||
console.log(`title: ${r.title}`)
|
||||
console.log(`employer: ${r.hiringOrgName}`)
|
||||
console.log(`occupation: ${r.occupation ?? "-"}`)
|
||||
console.log(`municipality: ${r.municipality ?? "-"}`)
|
||||
console.log(`published: ${r.publicationDate}`)
|
||||
console.log(`deadline: ${r.applicationDeadline ?? "-"}`)
|
||||
console.log("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineCommand, option } from "@bunli/core"
|
||||
import { z } from "zod"
|
||||
import { apiFetch, writeError } from "../helpers.js"
|
||||
|
||||
export const suggestions = defineCommand({
|
||||
name: "suggestions",
|
||||
description: "Typeahead suggestions for job title / keyword search",
|
||||
options: {
|
||||
query: option(z.string().optional(), {
|
||||
description: "Partial search string to complete",
|
||||
}),
|
||||
limit: option(z.coerce.number().optional(), {
|
||||
description: "Cap number of suggestions returned",
|
||||
}),
|
||||
format: option(z.enum(["json", "table", "plain"]).default("json"), {
|
||||
description: "Output format: json, table, plain",
|
||||
}),
|
||||
},
|
||||
handler: async ({ flags, signal }) => {
|
||||
if (signal.aborted) return
|
||||
|
||||
if (!flags.query) {
|
||||
writeError("--query is required", "MISSING_REQUIRED")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
query: flags.query,
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch<string[]>("/FindJob/GetTypeaheadSuggestions", params)
|
||||
|
||||
if (signal.aborted) return
|
||||
|
||||
let results = data
|
||||
if (flags.limit !== undefined) {
|
||||
results = results.slice(0, flags.limit)
|
||||
}
|
||||
|
||||
if (flags.format === "json") {
|
||||
console.log(JSON.stringify(results, 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(data: string[]): void {
|
||||
console.log("suggestion")
|
||||
for (const s of data) {
|
||||
console.log(s)
|
||||
}
|
||||
}
|
||||
|
||||
function outputPlain(data: string[]): void {
|
||||
for (const s of data) {
|
||||
console.log(s)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user