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,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)
}
}