hirify-cli 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/hirify.js ADDED
@@ -0,0 +1,1665 @@
1
+ #!/usr/bin/env node
2
+ // Hirify CLI: job search for AI agents.
3
+ //
4
+ // A thin client over `api.hirify.me/api/agent/*`, the same API the MCP server serves.
5
+ // No dependencies: Node 18+ (built-in fetch), runs through `npx hirify-cli`.
6
+ //
7
+ // Metering, same as on the server: lists and searches are free, and three actions are
8
+ // metered - reading a vacancy in full, revealing a contact, and applying. Repeating a read
9
+ // or a reveal on the same vacancy is free. No number for any of them is written down in
10
+ // this file: `hirify account show` reports what the server has left, and that is the only
11
+ // place any of them is true.
12
+ //
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join, dirname } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { createServer } from 'node:http'
18
+ import { spawn } from 'node:child_process'
19
+ import { randomBytes, createHash } from 'node:crypto'
20
+
21
+ const API = process.env.HIRIFY_API || 'https://api.hirify.me'
22
+ // The two public well-known documents the CLI is allowed to hardcode. OAuth discovery is
23
+ // unchanged; the Hirify agent document is where the CLI learns the manifest URL, so it never
24
+ // has to hardcode an agent operation path of its own.
25
+ const OAUTH_WELL_KNOWN = '/.well-known/oauth-authorization-server'
26
+ const AGENT_WELL_KNOWN = '/.well-known/hirify-agent'
27
+ const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'hirify')
28
+ // One file for either way of signing in. Working out where the current token came from
29
+ // is cheaper from a single `kind` field than from two files that may both exist.
30
+ const AUTH_FILE = join(CONFIG_DIR, 'auth.json')
31
+ // The key file from 0.1: still read for anyone who has one, never written again.
32
+ const LEGACY_KEY_FILE = join(CONFIG_DIR, 'key')
33
+
34
+ // What to ask for at sign-in when the server does not publish its own list. An OAuth token
35
+ // carries exactly what the person agreed to, and nothing can add an ability to it later
36
+ // without sending them back through the consent screen, so a sign-in that asks for too
37
+ // little is a sign-in the person has to repeat.
38
+ //
39
+ // This is a fallback, not the list. The authorization server publishes `scopes_supported`
40
+ // (RFC 8414) and that is what we ask for, because a list frozen here is a list that goes
41
+ // stale: on 18.08 the server had no `agent:feedback` and dropped it from the request in
42
+ // silence, and everyone who signed in that day still cannot send a report.
43
+ const FALLBACK_SCOPES = 'agent:read agent:reveal agent:apply agent:manage agent:feedback offline_access'
44
+ const CALLBACK_PATH = '/callback'
45
+ // How long we wait for the browser. The authorization code lives the same five minutes.
46
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000
47
+
48
+ // The two kinds of report the API takes. This one is ours to know: it is the word a
49
+ // person types, and the CLI has to tell them which words there are before it can send
50
+ // anything at all.
51
+ const FEEDBACK_TYPES = ['bug', 'feature']
52
+
53
+ // Read from package.json rather than repeated here: the server records which client sent
54
+ // a report, and a version that drifts from the published one makes that record useless.
55
+ const VERSION = (() => {
56
+ try {
57
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
58
+ return JSON.parse(readFileSync(pkg, 'utf8')).version || '0'
59
+ } catch {
60
+ return '0'
61
+ }
62
+ })()
63
+ const USER_AGENT = `hirify-cli/${VERSION}`
64
+
65
+ const HELP = `hirify - job search for AI agents
66
+
67
+ hirify intro what this can do, and in what order
68
+
69
+ hirify login sign in through your browser
70
+ hirify logout sign out on this computer
71
+ hirify account show your plan and allowances
72
+
73
+ hirify vacancy search <query> search the board [--limit N] [--page N]
74
+ hirify vacancy read <slug> one vacancy in full, with its text
75
+ hirify vacancy reveal <slug> where to apply: uses 1 reveal
76
+ hirify vacancy apply <slug> apply on Hirify [--profile N] [--cover T]
77
+
78
+ hirify feed list the feeds you saved on the site
79
+ hirify feed show <id> vacancies from one feed [--limit N] [--page N]
80
+ hirify feed create <name> save a search [--filters JSON] [--webhook N]
81
+ hirify feed deliver <id> change how a feed reaches you
82
+
83
+ hirify profile list the profiles you can apply with
84
+ hirify webhook list your delivery endpoints
85
+ hirify webhook create <name> <url>
86
+
87
+ hirify filter guide what search can filter on, from the server
88
+ hirify feedback send <kind> report a bug or ask for a feature
89
+ hirify api call <capability> invoke any capability [--data JSON] [--fields a,b]
90
+
91
+ --json raw JSON instead of text
92
+
93
+ Free: account show, feed list, feed show, vacancy search, profile list, webhook list,
94
+ filter guide.
95
+ Metered: vacancy read spends one of the day's vacancy opens, vacancy reveal spends 1 reveal,
96
+ and vacancy apply counts against its own daily allowance. Repeating a read or a reveal on the
97
+ same vacancy is free. vacancy apply also sends a real application to a recruiter and cannot be
98
+ taken back, so ask the person first.
99
+ What is left of each: hirify account show.
100
+
101
+ New here: hirify intro
102
+ A noun on its own lists its verbs: hirify vacancy
103
+ Rules for your agent: npx skills add hirifyme/hirify-cli
104
+ No browser (CI, servers): hirify auth <key>, or the HIRIFY_KEY variable.
105
+ Key: hirify.me/account/api-access`
106
+
107
+ // ── helpers ────────────────────────────────────────────────────────────────
108
+ // Exit codes are a stable contract, so a caller can branch on them: 0 is success, 1 is an
109
+ // ordinary error, and 2 means this Hirify speaks a newer manifest than this build can read -
110
+ // "update the CLI", told apart from "the command failed". These three do not change.
111
+ const EXIT_OK = 0
112
+ const EXIT_ERROR = 1
113
+ const EXIT_MANIFEST_UNSUPPORTED = 2
114
+
115
+ const die = (msg, code = EXIT_ERROR) => { console.error(`hirify: ${msg}`); process.exit(code) }
116
+
117
+ // Options that stand on their own. Everything else takes the next word as its value, and
118
+ // that rule is what lets `search` carry a filter the CLI has never heard of: the list of
119
+ // filters lives on the server, and a CLI that keeps its own copy is a CLI that lags.
120
+ //
121
+ // It used to be the other way round, a list of options known to take a value. That list
122
+ // was the ceiling: an option missing from it silently swallowed nothing and its value was
123
+ // read as data.
124
+ const BOOLEAN_FLAGS = new Set(['--json', '--help', '--no-browser', '--telegram', '--no-telegram', '--no-webhook'])
125
+
126
+ // Ours, not the API's. These never reach a query string: `--json` steers the CLI, and
127
+ // `--fields` chooses which fields a compact render prints.
128
+ const CLI_ONLY = new Set(['json', 'fields'])
129
+
130
+ /** Does this option take the word after it, or does it stand alone? */
131
+ const takesValue = (args, i) =>
132
+ !BOOLEAN_FLAGS.has(args[i]) && args[i + 1] !== undefined && !args[i + 1].startsWith('--')
133
+
134
+ const flag = (args, name) => {
135
+ const i = args.indexOf(name)
136
+ if (i !== -1) return args[i + 1]
137
+ const eq = args.find((a) => a.startsWith(`${name}=`))
138
+ return eq === undefined ? null : eq.slice(name.length + 1)
139
+ }
140
+
141
+ /**
142
+ * The arguments that are not options. The router finds the verb through this, and every
143
+ * command reads its slug or id through it, because reading `args[0]` directly made a flag
144
+ * look like one: `hirify webhook --json list` was refused as an unknown verb, and `hirify
145
+ * feed --json show 7` asked the server for a feed literally called "--json".
146
+ */
147
+ const positional = (args) => {
148
+ const rest = []
149
+ for (let i = 0; i < args.length; i++) {
150
+ if (args[i].startsWith('--')) {
151
+ if (takesValue(args, i)) i++
152
+ continue
153
+ }
154
+ rest.push(args[i])
155
+ }
156
+ return rest
157
+ }
158
+
159
+ /**
160
+ * Every option and the value it was given, as a plain map. `search` forwards these to the
161
+ * API unchanged, so a filter added on the server works from the terminal the same day,
162
+ * without a new flag being written here.
163
+ *
164
+ * Both `--key value` and `--key=value` are the same thing. A key given more than once is
165
+ * joined with a comma, which is how the site sends a filter with several values.
166
+ */
167
+ const options = (args) => {
168
+ const found = new Map()
169
+ for (let i = 0; i < args.length; i++) {
170
+ if (!args[i].startsWith('--')) continue
171
+ const eq = args[i].indexOf('=')
172
+ const name = eq === -1 ? args[i].slice(2) : args[i].slice(2, eq)
173
+ if (!name) continue
174
+ const value = eq !== -1 ? args[i].slice(eq + 1) : takesValue(args, i) ? args[++i] : 'true'
175
+ found.set(name, found.has(name) ? `${found.get(name)},${value}` : value)
176
+ }
177
+ return found
178
+ }
179
+
180
+ /**
181
+ * A number as the server sent it, or `null` when the field is not there. Every printed
182
+ * figure goes through this: a field the API stopped sending used to reach the screen as
183
+ * the word `undefined`, which reads like a fact about the account and is not one.
184
+ */
185
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
186
+
187
+ const out = (data, text) => {
188
+ if (process.argv.includes('--json')) console.log(JSON.stringify(data, null, 2))
189
+ else text()
190
+ }
191
+
192
+ // Text our own server wrote for the agent to read: application rules, address rejections.
193
+ // Passing it through beats paraphrasing, because paraphrasing drifts from the real rule.
194
+ const serverMessage = (body) => (typeof body?.message === 'string' && body.message ? body.message : null)
195
+
196
+ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
197
+ const now = () => Math.floor(Date.now() / 1000)
198
+
199
+ // ── stored access ──────────────────────────────────────────────────────────
200
+ function readSession() {
201
+ if (existsSync(AUTH_FILE)) {
202
+ try {
203
+ const s = JSON.parse(readFileSync(AUTH_FILE, 'utf8'))
204
+ if (s && typeof s.access_token === 'string' && s.access_token) return s
205
+ } catch {
206
+ // A damaged file is not worth crashing over: behave as if nobody is signed in.
207
+ }
208
+ }
209
+ if (existsSync(LEGACY_KEY_FILE)) {
210
+ const key = readFileSync(LEGACY_KEY_FILE, 'utf8').trim()
211
+ if (key) return { kind: 'key', access_token: key }
212
+ }
213
+ return null
214
+ }
215
+
216
+ function writeSession(session) {
217
+ mkdirSync(CONFIG_DIR, { recursive: true })
218
+ writeFileSync(AUTH_FILE, JSON.stringify(session, null, 2) + '\n', { mode: 0o600 })
219
+ }
220
+
221
+ function forgetSession() {
222
+ let had = false
223
+ for (const file of [AUTH_FILE, LEGACY_KEY_FILE]) {
224
+ if (existsSync(file)) { rmSync(file); had = true }
225
+ }
226
+ return had
227
+ }
228
+
229
+ const NOT_SIGNED_IN =
230
+ 'you are not signed in yet. Run `hirify login` and your browser will open.\n' +
231
+ ' No browser (CI, servers): `hirify auth <key>`, or set HIRIFY_KEY.\n' +
232
+ ' Key: hirify.me/account/api-access'
233
+
234
+ /**
235
+ * The token for a request. The environment variable wins: it is only ever set on
236
+ * purpose, on CI or a server, and a local sign-in should not quietly override it.
237
+ * A browser session is renewed a minute before it expires, so nobody meets a 401
238
+ * they could have avoided.
239
+ */
240
+ async function accessToken() {
241
+ if (process.env.HIRIFY_KEY) return process.env.HIRIFY_KEY
242
+
243
+ const session = readSession()
244
+ if (!session) die(NOT_SIGNED_IN)
245
+
246
+ if (session.kind === 'oauth' && session.expires_at && session.expires_at - 60 <= now()) {
247
+ return (await refreshSession(session)).access_token
248
+ }
249
+ return session.access_token
250
+ }
251
+
252
+ // ── talking to the API ─────────────────────────────────────────────────────
253
+ // `path` is a whole path under the API base, the way the manifest writes it
254
+ // (`/api/agent/...`). The CLI does not assemble it from parts of its own, so a path the
255
+ // server moves is followed from the manifest without a change here.
256
+ async function request(path, method, token, payload) {
257
+ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json', 'User-Agent': USER_AGENT }
258
+ if (payload) headers['Content-Type'] = 'application/json'
259
+ try {
260
+ return await fetch(`${API}${path}`, {
261
+ method,
262
+ headers,
263
+ body: payload ? JSON.stringify(payload) : undefined,
264
+ })
265
+ } catch (e) {
266
+ die(`the network seems to be unavailable: ${e.message}`)
267
+ }
268
+ }
269
+
270
+ /**
271
+ * `allow` lists statuses the caller wants to read itself instead of dying on. Feedback
272
+ * needs it: a 429 there means "too many reports", not "your reveals are used up", and
273
+ * saying the wrong one sends the person looking in the wrong place.
274
+ */
275
+ async function api(path, { method = 'GET', payload = null, allow = [], raw = false } = {}) {
276
+ let res = await request(path, method, await accessToken(), payload)
277
+
278
+ // A 401 on a live session is ordinary: the access token is dropped when the refresh
279
+ // token rotates. One quiet exchange and a retry, and only then ask for a new sign-in.
280
+ if (res.status === 401 && !process.env.HIRIFY_KEY) {
281
+ const session = readSession()
282
+ if (session?.kind === 'oauth' && session.refresh_token) {
283
+ const fresh = await refreshSession(session)
284
+ res = await request(path, method, fresh.access_token, payload)
285
+ }
286
+ }
287
+
288
+ // `api call` reads every answer itself, refusal included: that is what it is for, and a
289
+ // sentence of ours in place of the server's own reply would defeat the point of `api call`.
290
+ if (raw) {
291
+ const text = await res.text()
292
+ let body = null
293
+ try {
294
+ body = JSON.parse(text)
295
+ } catch {
296
+ // Not JSON. The text is handed over as it came rather than thrown away.
297
+ }
298
+ return { status: res.status, body, text }
299
+ }
300
+
301
+ if (allow.includes(res.status)) {
302
+ return {
303
+ status: res.status,
304
+ body: await res.json().catch(() => null),
305
+ retryAfter: res.headers.get('retry-after'),
306
+ }
307
+ }
308
+
309
+ if (res.status === 401) {
310
+ die(process.env.HIRIFY_KEY || readSession()?.kind === 'key'
311
+ ? 'the key was not accepted (401). It may have been revoked, or copied incompletely.'
312
+ : 'your sign-in is no longer valid. Please run `hirify login` again.')
313
+ }
314
+ if (res.status === 403) die('no access (403). This needs an active paid plan, or the sign-in is missing a permission.')
315
+ if (res.status === 404) die('not found (404).')
316
+ if (res.status === 429) {
317
+ // Several walls answer 429: a metered action has run out, or the requests came too
318
+ // fast. The metered ones carry a quota block naming themselves in `quota.action`.
319
+ // Naming the wrong wall leaves someone waiting out a limit they still have, or
320
+ // spending reveals they no longer do.
321
+ const spent = await res.json().catch(() => null)
322
+ const wait = Number(res.headers.get('retry-after'))
323
+ const action = spent?.quota?.action
324
+ if (action === 'vacancy_opens') {
325
+ die('you have opened as many vacancies today as the daily allowance covers.' +
326
+ '\n Feeds and search still work, and so does anything already read today.')
327
+ }
328
+ if (action === 'contact_reveals') die('you have no reveals left right now. Reading still works.')
329
+ if (action === 'apply') {
330
+ die('you have sent as many applications today as the daily allowance covers.' +
331
+ '\n Reading and revealing still work. What is left: hirify account show')
332
+ }
333
+ // A budget we have no sentence for yet. Name the one the server named rather than
334
+ // guessing: guessing is how a spent read once came back as a spent reveal.
335
+ if (spent?.quota) {
336
+ die(`today's allowance for ${String(action ?? 'that action').replace(/_/g, ' ')} is used up.` +
337
+ '\n What is left: hirify account show')
338
+ }
339
+ die('too many requests in a short time.' +
340
+ (Number.isFinite(wait) && wait > 0 ? ` Please try again in ${wait} seconds.` : ' Please try again in a minute.'))
341
+ }
342
+ const body = await res.json().catch(() => null)
343
+ if (!res.ok) {
344
+ // Everything expected is handled above. What lands here is not, and it used to print
345
+ // the raw response body: status codes and internal fields in front of a person. The
346
+ // answer is for debugging, not for reading, so it goes behind HIRIFY_DEBUG.
347
+ if (process.env.HIRIFY_DEBUG) {
348
+ console.error(`hirify: server answered ${res.status}: ${body ? JSON.stringify(body) : '(empty body)'}`)
349
+ }
350
+ die(res.status >= 500
351
+ ? 'something went wrong on our side. Please try again in a minute.'
352
+ : 'that command could not be completed. Please check it and try again.')
353
+ }
354
+ // Return the WHOLE body: lists carry `meta` (total/last_page) next to `data`, and an
355
+ // agent reading --json needs it too. Unwrapping happens in the commands.
356
+ return body
357
+ }
358
+
359
+ // ── the capability manifest ─────────────────────────────────────────────────
360
+ // The version of the manifest this CLI can read. A server that answers with a higher one
361
+ // speaks a document this build cannot interpret safely, and the CLI says so rather than
362
+ // guessing. Lower or equal is fine: unknown fields are ignored, not refused.
363
+ const SUPPORTED_SCHEMA_VERSION = 1
364
+
365
+ /**
366
+ * Where the manifest lives, learned from the public Hirify agent document rather than
367
+ * hardcoded. The CLI is allowed to know the well-known path and nothing past it, so the
368
+ * address of every operation stays the server's to move.
369
+ *
370
+ * Cached for the process: the document does not change under one command.
371
+ */
372
+ let agentDiscoveryCache = null
373
+ async function agentDiscovery() {
374
+ if (agentDiscoveryCache) return agentDiscoveryCache
375
+ try {
376
+ const res = await fetch(`${API}${AGENT_WELL_KNOWN}`, { headers: { Accept: 'application/json' } })
377
+ if (!res.ok) return (agentDiscoveryCache = { manifest_url: null, intro: null })
378
+ const doc = await res.json()
379
+ agentDiscoveryCache = {
380
+ manifest_url: typeof doc.manifest_url === 'string' && doc.manifest_url ? doc.manifest_url : null,
381
+ // Release-approved intro text, published here so it can be refreshed without a new CLI.
382
+ // `hirify intro` prints it and falls back to the built-in text when it is absent.
383
+ intro: typeof doc.intro === 'string' && doc.intro ? doc.intro : null,
384
+ }
385
+ } catch {
386
+ // Unreachable is the same fact as unusable to the caller: no manifest, so no operations.
387
+ agentDiscoveryCache = { manifest_url: null, intro: null }
388
+ }
389
+ return agentDiscoveryCache
390
+ }
391
+
392
+ // The manifest, held for this process only and never written to disk, so a fresh run always
393
+ // starts from the server's current document. `etag` rides along so a later fetch in the same
394
+ // process can revalidate instead of downloading the whole thing again.
395
+ let manifestCache = null
396
+
397
+ /**
398
+ * Fetch and validate the manifest once per process. The first call downloads and checks it;
399
+ * a later call in the same process sends the stored ETag and reuses the cache on a 304, so
400
+ * the document is parsed and validated once even if it is asked for again.
401
+ */
402
+ async function loadManifest() {
403
+ const { manifest_url } = await agentDiscovery()
404
+ if (!manifest_url) {
405
+ die('could not reach Hirify to load what it can do. Please check your connection and try again.')
406
+ }
407
+
408
+ const headers = {
409
+ Authorization: `Bearer ${await accessToken()}`,
410
+ Accept: 'application/json',
411
+ 'User-Agent': USER_AGENT,
412
+ }
413
+ if (manifestCache?.etag) headers['If-None-Match'] = manifestCache.etag
414
+
415
+ let res
416
+ try {
417
+ res = await fetch(manifest_url, { headers })
418
+ } catch (e) {
419
+ if (manifestCache) return manifestCache.doc
420
+ die(`the network seems to be unavailable: ${e.message}`)
421
+ }
422
+
423
+ // The manifest is behind the sign-in, so a rotated access token answers 401 once. One
424
+ // quiet exchange and a retry, the same as an ordinary request.
425
+ if (res.status === 401 && !process.env.HIRIFY_KEY) {
426
+ const session = readSession()
427
+ if (session?.kind === 'oauth' && session.refresh_token) {
428
+ headers.Authorization = `Bearer ${(await refreshSession(session)).access_token}`
429
+ try {
430
+ res = await fetch(manifest_url, { headers })
431
+ } catch (e) {
432
+ die(`the network seems to be unavailable: ${e.message}`)
433
+ }
434
+ }
435
+ }
436
+
437
+ if (res.status === 304 && manifestCache) return manifestCache.doc
438
+ if (res.status === 401) {
439
+ die(process.env.HIRIFY_KEY || readSession()?.kind === 'key'
440
+ ? 'the key was not accepted (401). It may have been revoked, or copied incompletely.'
441
+ : 'your sign-in is no longer valid. Please run `hirify login` again.')
442
+ }
443
+ if (!res.ok) die('could not load what Hirify can do right now. Please try again in a minute.')
444
+
445
+ const doc = await res.json().catch(() => null)
446
+ validateManifest(doc)
447
+ manifestCache = { doc, etag: res.headers.get('etag') || null }
448
+ return doc
449
+ }
450
+
451
+ /** Refuse a manifest this build cannot read, and say which way to fix it. */
452
+ function validateManifest(doc) {
453
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
454
+ die('Hirify sent something this CLI could not read as a manifest. Please try again in a minute.')
455
+ }
456
+ const version = doc.schema_version
457
+ if (typeof version !== 'number' || !Number.isFinite(version)) {
458
+ die('Hirify sent a manifest without a version. Please try again in a minute.')
459
+ }
460
+ if (version > SUPPORTED_SCHEMA_VERSION) {
461
+ die('this Hirify speaks a newer manifest than this CLI can read' +
462
+ ` (its version is ${version}, this CLI reads ${SUPPORTED_SCHEMA_VERSION}).` +
463
+ '\n Please update the CLI: npm install -g hirify-cli, or run it with npx hirify-cli.',
464
+ EXIT_MANIFEST_UNSUPPORTED)
465
+ }
466
+ if (!Array.isArray(doc.capabilities)) {
467
+ die('Hirify sent a manifest with no list of what it can do. Please try again in a minute.')
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Find one capability by its stable id. This is how a command reaches the server: it names
473
+ * the capability it wants, and the manifest says which method and path answer it today.
474
+ */
475
+ async function resolveCapability(id) {
476
+ const manifest = await loadManifest()
477
+ const cap = manifest.capabilities.find((c) => c && c.id === id)
478
+ if (!cap || typeof cap.method !== 'string' || typeof cap.path !== 'string') {
479
+ die(`this Hirify does not offer "${id}". It may be an older server, or the operation has moved.`)
480
+ }
481
+ return cap
482
+ }
483
+
484
+ /** Put values into a path template: `/api/agent/vacancies/{slug}` plus `{slug}` -> a path. */
485
+ function fillPath(template, params = {}) {
486
+ return template.replace(/\{([a-z_]+)\}/g, (_, name) => {
487
+ const value = params[name]
488
+ if (value === undefined || value === null) {
489
+ die(`this call needs "${name}", and it was not given.`)
490
+ }
491
+ return encodeURIComponent(String(value))
492
+ })
493
+ }
494
+
495
+ /**
496
+ * Call a capability by its id. The command supplies the parts it owns - the path values, the
497
+ * query, the body - and the manifest supplies the method and the path, so no route is written
498
+ * into this file.
499
+ */
500
+ async function callCapability(id, { params = {}, query = null, payload = null, allow = [], raw = false } = {}) {
501
+ const cap = await resolveCapability(id)
502
+ let path = fillPath(cap.path, params)
503
+ const qs = query ? query.toString() : ''
504
+ if (qs) path += `?${qs}`
505
+ return api(path, { method: cap.method, payload, allow, raw })
506
+ }
507
+
508
+ // ── signing in through the browser ─────────────────────────────────────────
509
+ /**
510
+ * Where the authorization server lives. We ask it rather than hardcode it (RFC 8414),
511
+ * so the CLI follows if the endpoints move. No document, no problem: use the defaults.
512
+ */
513
+ async function discover() {
514
+ const fallback = {
515
+ authorization_endpoint: `${API}/oauth/authorize`,
516
+ token_endpoint: `${API}/oauth/token`,
517
+ registration_endpoint: `${API}/oauth/register`,
518
+ scopes: FALLBACK_SCOPES,
519
+ }
520
+ try {
521
+ const res = await fetch(`${API}${OAUTH_WELL_KNOWN}`, { headers: { Accept: 'application/json' } })
522
+ if (!res.ok) return fallback
523
+ const meta = await res.json()
524
+ // The abilities are the server's to name. We ask for every one it publishes, because
525
+ // the person is agreeing once and cannot be given a missing one afterwards.
526
+ const published = Array.isArray(meta.scopes_supported)
527
+ ? meta.scopes_supported.filter((s) => typeof s === 'string' && s).join(' ')
528
+ : ''
529
+ return {
530
+ authorization_endpoint: meta.authorization_endpoint || fallback.authorization_endpoint,
531
+ token_endpoint: meta.token_endpoint || fallback.token_endpoint,
532
+ registration_endpoint: meta.registration_endpoint || fallback.registration_endpoint,
533
+ scopes: published || fallback.scopes,
534
+ }
535
+ } catch {
536
+ return fallback
537
+ }
538
+ }
539
+
540
+ /** A form body, not JSON: RFC 6749 requires it and the server expects it. */
541
+ async function postForm(url, params) {
542
+ let res
543
+ try {
544
+ res = await fetch(url, {
545
+ method: 'POST',
546
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
547
+ body: new URLSearchParams(params),
548
+ })
549
+ } catch (e) {
550
+ die(`the network seems to be unavailable: ${e.message}`)
551
+ }
552
+ const body = await res.json().catch(() => null)
553
+ return { ok: res.ok, status: res.status, body }
554
+ }
555
+
556
+ /**
557
+ * Listen for the browser on the loopback interface. The port is ephemeral (0), so it is
558
+ * always free and two sign-ins in a row never fight over the same number.
559
+ */
560
+ async function startCallbackServer() {
561
+ let settle
562
+ const received = new Promise((resolve) => { settle = resolve })
563
+
564
+ const server = createServer((req, res) => {
565
+ const url = new URL(req.url, 'http://127.0.0.1')
566
+ if (url.pathname !== CALLBACK_PATH) {
567
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
568
+ res.end('not found')
569
+ return
570
+ }
571
+ const error = url.searchParams.get('error')
572
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
573
+ res.end(browserPage(error))
574
+ settle({
575
+ code: url.searchParams.get('code'),
576
+ state: url.searchParams.get('state'),
577
+ error,
578
+ // Only the server attaches a description, and only when the server itself refused.
579
+ // A person pressing "deny" arrives without one, which is how we tell them apart.
580
+ description: url.searchParams.get('error_description'),
581
+ })
582
+ })
583
+
584
+ await new Promise((resolve, reject) => {
585
+ const onError = (e) => reject(e)
586
+ server.once('error', onError)
587
+ server.listen(0, '127.0.0.1', () => {
588
+ server.off('error', onError)
589
+ // Past this point one malformed browser request should not bring the CLI down.
590
+ server.on('error', () => {})
591
+ resolve()
592
+ })
593
+ })
594
+
595
+ return { port: server.address().port, received, close: () => server.close() }
596
+ }
597
+
598
+ /**
599
+ * What the person sees in the browser. It does not say "you are signed in": the code is
600
+ * exchanged for tokens after this page is served, and the page cannot promise the result.
601
+ * The result belongs in the terminal.
602
+ */
603
+ function browserPage(error) {
604
+ const title = error ? 'Sign-in was not completed' : 'Confirmation received'
605
+ const text = error
606
+ ? 'Please return to your terminal and try again.'
607
+ : 'You can close this tab and return to your terminal.'
608
+ return `<!doctype html><html lang="en"><meta charset="utf-8">
609
+ <meta name="viewport" content="width=device-width, initial-scale=1">
610
+ <title>Hirify</title>
611
+ <style>
612
+ body { margin:0; min-height:100vh; display:grid; place-items:center;
613
+ font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
614
+ color:#1a1a1a; background:#fafafa; }
615
+ main { max-width:32rem; padding:2rem; text-align:center; }
616
+ h1 { font-size:1.4rem; margin:0 0 .5rem; }
617
+ p { margin:0; color:#555; }
618
+ </style>
619
+ <main><h1>${title}</h1><p>${text}</p></main></html>`
620
+ }
621
+
622
+ /**
623
+ * Open the browser. Success here means the command started: whether a window actually
624
+ * appeared is not something we can know, so the link is printed either way.
625
+ */
626
+ function openBrowser(url) {
627
+ const candidates = process.env.BROWSER
628
+ ? [[process.env.BROWSER, [url]]]
629
+ : process.platform === 'darwin' ? [['open', [url]]]
630
+ : process.platform === 'win32' ? [['cmd', ['/c', 'start', '', url]]]
631
+ : [['xdg-open', [url]], ['gio', ['open', url]], ['sensible-browser', [url]], ['x-www-browser', [url]]]
632
+
633
+ const tryOne = (i) => new Promise((resolve) => {
634
+ if (i >= candidates.length) return resolve(false)
635
+ const [cmd, args] = candidates[i]
636
+ let child
637
+ try {
638
+ child = spawn(cmd, args, { stdio: 'ignore', detached: true })
639
+ } catch {
640
+ return resolve(tryOne(i + 1))
641
+ }
642
+ child.once('error', () => resolve(tryOne(i + 1)))
643
+ child.once('spawn', () => { child.unref(); resolve(true) })
644
+ })
645
+
646
+ return tryOne(0)
647
+ }
648
+
649
+ async function cmdLogin(args) {
650
+ const noBrowser = args.includes('--no-browser')
651
+ const endpoints = await discover()
652
+
653
+ let listener
654
+ try {
655
+ listener = await startCallbackServer()
656
+ } catch (e) {
657
+ die('could not open a local address for the browser to answer on: ' + e.message +
658
+ '\n Please sign in with a key instead: `hirify auth <key>` (hirify.me/account/api-access).')
659
+ }
660
+
661
+ const redirectUri = `http://127.0.0.1:${listener.port}${CALLBACK_PATH}`
662
+
663
+ // A client is registered on every sign-in: the return address is matched exactly and
664
+ // our port is new each time. The client holds no secret; PKCE is what protects the code.
665
+ let clientId
666
+ try {
667
+ const res = await fetch(endpoints.registration_endpoint, {
668
+ method: 'POST',
669
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
670
+ body: JSON.stringify({ client_name: 'Hirify CLI', redirect_uris: [redirectUri] }),
671
+ })
672
+ const body = await res.json().catch(() => null)
673
+ if (!res.ok || !body?.client_id) {
674
+ listener.close()
675
+ die(`could not start the sign-in: the server answered ${res.status}. Please try again.`)
676
+ }
677
+ clientId = body.client_id
678
+ } catch (e) {
679
+ listener.close()
680
+ die(`the network seems to be unavailable: ${e.message}`)
681
+ }
682
+
683
+ const verifier = b64url(randomBytes(32))
684
+ const challenge = b64url(createHash('sha256').update(verifier).digest())
685
+ const state = b64url(randomBytes(16))
686
+
687
+ const authUrl = `${endpoints.authorization_endpoint}?` + new URLSearchParams({
688
+ response_type: 'code',
689
+ client_id: clientId,
690
+ redirect_uri: redirectUri,
691
+ scope: endpoints.scopes,
692
+ state,
693
+ code_challenge: challenge,
694
+ code_challenge_method: 'S256',
695
+ })
696
+
697
+ const opened = noBrowser ? false : await openBrowser(authUrl)
698
+ console.log(opened
699
+ ? 'Opening your browser. Please confirm access on hirify.me.'
700
+ : 'Please open this link in your browser and confirm access:')
701
+ if (!opened) console.log(`\n${authUrl}\n`)
702
+ else console.log(`If it did not open, use this link:\n${authUrl}`)
703
+
704
+ const timeout = new Promise((resolve) => setTimeout(() => resolve({ timedOut: true }), LOGIN_TIMEOUT_MS))
705
+ const answer = await Promise.race([listener.received, timeout])
706
+ listener.close()
707
+
708
+ if (answer.timedOut) {
709
+ // Older servers show a refusal as a page on their own domain instead of returning it
710
+ // here, and then the browser stops there while we wait out the clock. This hint is
711
+ // the only place a person learns it was about access rather than about the command.
712
+ die('we did not get a confirmation in the browser. Please run `hirify login` again.' +
713
+ '\n If the browser said API access is not enabled, that is about your plan,' +
714
+ '\n not the command: hirify.me/account/api-access')
715
+ }
716
+ if (answer.error === 'access_denied') {
717
+ // The server sends its description for a program to read, in ASCII, as the spec
718
+ // requires. A person gets our own sentence; the machine string stays behind
719
+ // HIRIFY_DEBUG rather than sitting in the middle of the normal output.
720
+ if (answer.description) {
721
+ if (process.env.HIRIFY_DEBUG) console.error(`hirify: the server refused: ${answer.description}`)
722
+ die('API access is not enabled for this account, and nothing was saved.' +
723
+ '\n Details and how to enable it: hirify.me/account/api-access')
724
+ }
725
+ die('access was not confirmed and nothing was saved. If that was a mistake, run `hirify login` again.')
726
+ }
727
+ if (answer.error) {
728
+ die('the sign-in did not finish. Please run `hirify login` again.')
729
+ }
730
+ if (answer.state !== state) {
731
+ die('that answer came from a different sign-in. Please run `hirify login` again.')
732
+ }
733
+ if (!answer.code) {
734
+ die('the browser came back without a confirmation code. Please run `hirify login` again.')
735
+ }
736
+
737
+ const { ok, body } = await postForm(endpoints.token_endpoint, {
738
+ grant_type: 'authorization_code',
739
+ client_id: clientId,
740
+ code: answer.code,
741
+ redirect_uri: redirectUri,
742
+ code_verifier: verifier,
743
+ })
744
+
745
+ if (!ok || !body?.access_token) {
746
+ // The confirmation code lives five minutes and works once. Most arrivals here simply
747
+ // took too long, so the useful advice is to start again.
748
+ die('the sign-in did not finish: the confirmation expired or was already used.' +
749
+ '\n Please run `hirify login` again.')
750
+ }
751
+
752
+ writeSession({
753
+ kind: 'oauth',
754
+ issuer: API,
755
+ client_id: clientId,
756
+ token_endpoint: endpoints.token_endpoint,
757
+ access_token: body.access_token,
758
+ refresh_token: body.refresh_token || null,
759
+ scope: body.scope || endpoints.scopes,
760
+ expires_at: body.expires_in ? now() + Number(body.expires_in) : null,
761
+ })
762
+
763
+ console.log(`\nSigned in. Access saved to ${AUTH_FILE}`)
764
+ // Show the remaining limit right away: it is the first thing anyone asks anyway.
765
+ try {
766
+ await cmdAccountShow()
767
+ } catch {
768
+ // The sign-in worked; the plan summary is a nicety, not a requirement.
769
+ }
770
+ console.log('\nRules for your agent: npx skills add hirifyme/hirify-cli')
771
+ }
772
+
773
+ /**
774
+ * Trade the refresh token for a new pair. It rotates on every exchange, so the file is
775
+ * written immediately: losing the new refresh token costs more than one extra write.
776
+ */
777
+ async function refreshSession(session) {
778
+ if (!session.refresh_token) {
779
+ die('your sign-in has expired. Please run `hirify login` again.')
780
+ }
781
+
782
+ const { ok, body } = await postForm(session.token_endpoint || `${API}/oauth/token`, {
783
+ grant_type: 'refresh_token',
784
+ client_id: session.client_id,
785
+ refresh_token: session.refresh_token,
786
+ })
787
+
788
+ if (!ok || !body?.access_token) {
789
+ die('your sign-in has expired. Please run `hirify login` again.')
790
+ }
791
+
792
+ const fresh = {
793
+ ...session,
794
+ access_token: body.access_token,
795
+ refresh_token: body.refresh_token || session.refresh_token,
796
+ scope: body.scope || session.scope,
797
+ expires_at: body.expires_in ? now() + Number(body.expires_in) : null,
798
+ }
799
+ writeSession(fresh)
800
+ return fresh
801
+ }
802
+
803
+ function cmdLogout() {
804
+ console.log(forgetSession()
805
+ ? 'Signed out on this computer.'
806
+ : 'Nobody is signed in here.')
807
+ }
808
+
809
+ // ── commands ───────────────────────────────────────────────────────────────
810
+ async function cmdAccountShow() {
811
+ const body = await callCapability('account.status')
812
+ const d = body?.data ?? {}
813
+ const left = count(d?.quota?.reveal?.remaining)
814
+ // Reading one vacancy in full has its own daily allowance, so it gets its own line.
815
+ // Without it an agent planning a session can only find the wall by hitting it.
816
+ const reads = count(d?.quota?.read?.remaining)
817
+ // So does applying, and this line was missing while the skill told agents that applying
818
+ // spends nothing. The server has reported `quota.apply` all along; dropping a budget the
819
+ // server publishes is the same error as inventing one, pointing the other way.
820
+ const applies = count(d?.quota?.apply?.remaining)
821
+ const u = d?.usage?.reveal ?? {}
822
+ // Whatever the server sent, and nothing else: a period it left out is simply not shown.
823
+ const spent = [[u.today, 'today'], [u.last_7d, 'in 7d'], [u.last_30d, 'in 30d']]
824
+ .filter(([n]) => count(n) !== null)
825
+ .map(([n, when]) => `${n} ${when}`)
826
+ out(body, () => {
827
+ console.log(`plan: ${d?.plan ?? '-'}`)
828
+ // How many are left, and no denominator. Reveals are not a fraction of a number the
829
+ // person has, so `N of M` would be inventing the M.
830
+ console.log(`reveals: ${left === null ? '-' : `${left} left`}`)
831
+ if (reads !== null) console.log(`opens: ${reads} left today`)
832
+ if (applies !== null) console.log(`applies: ${applies} left today`)
833
+ if (spent.length) console.log(`usage: ${spent.join(' · ')}`)
834
+ })
835
+ }
836
+
837
+ async function cmdFeedList() {
838
+ const body = await callCapability('feeds.list')
839
+ const list = body?.data ?? []
840
+ out(body, () => {
841
+ if (!list.length) return console.log('You have no feeds yet. Save a filter on hirify.me and it becomes a feed.')
842
+ for (const f of list) {
843
+ const off = f.is_active === false ? ' (off)' : ''
844
+ console.log(`${String(f.id ?? '-').padEnd(6)} ${f.name || '(untitled)'}${off}`)
845
+ }
846
+ console.log(`\nVacancies from a feed: hirify feed show <id>`)
847
+ })
848
+ }
849
+
850
+ /**
851
+ * The head of a vacancy, printed the same whether it comes from a list or from `read`.
852
+ * Fields come straight from the API's vacancy resource: a card carries no contacts by
853
+ * design, and for premium companies the name arrives masked.
854
+ *
855
+ * One function on purpose. A card that looks different depending on which command drew
856
+ * it is a card an agent has to learn twice.
857
+ */
858
+ function vacancyHead(v) {
859
+ // `company_masked` is a FLAG meaning "the name is hidden until you reveal", not the
860
+ // name itself. It used to be printed as it came, and cards ended up saying "- true".
861
+ const company = v.company || (v.company_masked ? 'company hidden' : '-')
862
+ // Some of these arrive as arrays: live data has `work_format: []` next to
863
+ // `employee_type: ['employment']`. An empty array is truthy, so it used to survive
864
+ // the filter and print as a blank slot between two separators: "[usa · · b2]".
865
+ const label = (x) => (Array.isArray(x) ? x.filter(Boolean).join('/') : x)
866
+ const bits = [v.remote_type, v.work_format, v.employee_type, v.english_level].map(label).filter(Boolean)
867
+ if (v.salary && (v.salary.min || v.salary.max)) {
868
+ const { min, max, currency } = v.salary
869
+ bits.push([min, max].filter(Boolean).join('-') + (currency ? ` ${currency}` : ''))
870
+ }
871
+ if (v.verified) bits.push('verified')
872
+ // The slug is the handle every other command takes, so it leads the card. A card
873
+ // without one is still worth printing for its title, but the first line has to say
874
+ // that there is nothing to copy, not print the word `undefined` where a slug goes.
875
+ return `${v.slug ?? '-'}\n ${v.title || '-'} · ${company}${bits.length ? `\n [${bits.join(' · ')}]` : ''}`
876
+ }
877
+
878
+ function printVacancies(list, meta) {
879
+ if (!list.length) return console.log('Nothing found.')
880
+ for (const v of list) console.log(vacancyHead(v))
881
+
882
+ const page = count(meta?.page)
883
+ const perPage = count(meta?.per_page)
884
+ const lastPage = count(meta?.last_page)
885
+ const total = count(meta?.total)
886
+
887
+ // `of N` is printed only when N is larger than what came back, because only then is it a
888
+ // total and not a restatement of the page. The agent search endpoint currently answers
889
+ // with the size of the page it just returned, and "Showing 50 of 50" reads as "that is
890
+ // the whole board", which sends an agent away from vacancies that are there. The moment
891
+ // the server sends a real total, this prints it, with no change here.
892
+ const of = total !== null && total > list.length ? ` of ${total}` : ''
893
+ // `of L` once there is more than one page, on the last page too: "page 4 of 4" is how
894
+ // you know you have reached the end rather than lost the rest.
895
+ const where = page === null ? '' : `, page ${page}${lastPage !== null && lastPage > 1 ? ` of ${lastPage}` : ''}`
896
+ console.log(`\nShowing ${list.length}${of}${where}.`)
897
+ // The order the product asks for: read what looks right, reveal only what fits.
898
+ console.log('Read one: hirify vacancy read <slug>')
899
+ console.log('Where to apply: hirify vacancy reveal <slug> (uses 1 reveal)')
900
+
901
+ // A full page is the only honest sign that there may be another one while `last_page`
902
+ // says otherwise. Offering the next page costs nothing if it turns out to be empty.
903
+ const more = lastPage !== null && page !== null && lastPage > page
904
+ ? true
905
+ : perPage !== null && list.length >= perPage
906
+ if (more && page !== null) console.log(`More: add --page ${page + 1}`)
907
+ }
908
+
909
+ /** The vacancies in one saved feed, using the criteria the feed already holds. */
910
+ async function cmdFeedShow(args, words) {
911
+ const [id] = words
912
+ if (!id) die('a feed id is required: hirify feed show <id> (list them with hirify feed list)')
913
+
914
+ // A feed already carries its own criteria, so only the two that say which slice of it to
915
+ // return travel from here. Without `--page` the second page of a feed was unreachable.
916
+ const p = new URLSearchParams()
917
+ const limit = flag(args, '--limit'); if (limit) p.set('per_page', limit)
918
+ const page = flag(args, '--page'); if (page) p.set('page', page)
919
+
920
+ const body = await callCapability('feeds.vacancies', { params: { feed_id: id }, query: p })
921
+ out(body, () => printVacancies(body?.data ?? [], body?.meta))
922
+ }
923
+
924
+ /**
925
+ * Search the board. The words are the phrase to look for; every option is passed on to the
926
+ * API as it was written.
927
+ *
928
+ * The CLI keeps no list of filters on purpose. The endpoint accepts the same criteria the
929
+ * site's own filter form produces, and the site can express all of them: a CLI that names
930
+ * them one flag at a time decides what is expressible, and it decided wrong for a long time.
931
+ * So every option travels by one rule, and a criterion added on the server works from here
932
+ * the day it ships.
933
+ *
934
+ * This is also why no example here names a criterion. `hirify filter guide` is the
935
+ * vocabulary, the server writes it, and an example frozen in this file would be a second
936
+ * answer to the same question, going stale at its own pace.
937
+ */
938
+ async function cmdVacancySearch(args, words) {
939
+ const p = new URLSearchParams()
940
+
941
+ // The phrase first, so an explicit `--search` still wins if someone writes both.
942
+ //
943
+ // `search` and `per_page` are the only two parameter names this file is allowed to know,
944
+ // and they are structural rather than vocabulary: this command exists to put the words on
945
+ // `search`, and `--limit` has to land somewhere. Every other name belongs to the server
946
+ // and is fetched with `hirify filter guide`. Adding a third name here is the regression.
947
+ const phrase = words.join(' ')
948
+ if (phrase) p.set('search', phrase)
949
+
950
+ for (const [name, value] of options(args)) {
951
+ if (CLI_ONLY.has(name)) continue
952
+ // `--limit` is what this CLI has always called it. The API calls it `per_page`, and
953
+ // that is the name the server publishes, so both arrive at the same parameter.
954
+ p.set(name === 'limit' ? 'per_page' : name, value)
955
+ }
956
+
957
+ const body = await callCapability('vacancies.search', { query: p })
958
+ out(body, () => printVacancies(body?.data ?? [], body?.meta))
959
+ }
960
+
961
+ /**
962
+ * The description as the site shows it, turned into something a terminal can print. The
963
+ * API sends it as HTML (`description_format`), so the tags have to go somewhere: a block
964
+ * ends with a line break and a list item starts with a dash. Nothing is dropped or
965
+ * shortened here, and `--json` hands over the original untouched.
966
+ */
967
+ function asText(html) {
968
+ return String(html)
969
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
970
+ .replace(/<br\s*\/?>/gi, '\n')
971
+ .replace(/<li\b[^>]*>/gi, '- ')
972
+ .replace(/<\/(p|div|h[1-6]|li|tr|blockquote|ul|ol|table)>/gi, '\n')
973
+ .replace(/<[^>]+>/g, '')
974
+ .replace(/&nbsp;/gi, ' ')
975
+ .replace(/&lt;/gi, '<')
976
+ .replace(/&gt;/gi, '>')
977
+ .replace(/&quot;/gi, '"')
978
+ .replace(/&#0?39;|&apos;/gi, "'")
979
+ // Last, or `&amp;lt;` would come out as `<` rather than as the `&lt;` somebody wrote.
980
+ .replace(/&amp;/gi, '&')
981
+ .replace(/[ \t]+$/gm, '')
982
+ .replace(/\n{3,}/g, '\n\n')
983
+ .trim()
984
+ }
985
+
986
+ /** A timestamp as the day it names. The hour a vacancy was imported tells nobody anything. */
987
+ const dateOnly = (v) => (typeof v === 'string' && v.length >= 10 ? v.slice(0, 10) : null)
988
+
989
+ /**
990
+ * The rows under the head, each one printed only when the server sent something for it.
991
+ * They are collected rather than printed straight, so a vacancy that happens to carry
992
+ * none of them does not get a blank line standing in for the block.
993
+ */
994
+ function detailRows(pairs) {
995
+ return pairs
996
+ .map(([label, value]) => [label, Array.isArray(value) ? value.filter(Boolean).join(' · ') : value])
997
+ .filter(([, text]) => text)
998
+ .map(([label, text]) => `${(label + ':').padEnd(10)}${text}`)
999
+ }
1000
+
1001
+ /**
1002
+ * One vacancy in full, with the text a person reads on the site. This is the command an
1003
+ * agent shortlists with, so it is deliberately the cheap one: it counts against the day's
1004
+ * vacancy opens, which is a generous allowance, and not against the reveal budget.
1005
+ *
1006
+ * The same vacancy read twice in a day costs nothing the second time.
1007
+ */
1008
+ async function cmdVacancyRead(args, words) {
1009
+ const [slug] = words
1010
+ if (!slug) {
1011
+ die('a vacancy slug is required: hirify vacancy read <slug>' +
1012
+ '\n Slugs come from hirify vacancy search or hirify feed show.')
1013
+ }
1014
+
1015
+ const res = await callCapability('vacancies.read', { params: { slug }, allow: [200, 404] })
1016
+ if (res.status === 404) die('there is no vacancy with that slug.')
1017
+
1018
+ const d = res.body?.data ?? {}
1019
+ // Codes, names and plain strings all arrive in these lists. English first: the rest of
1020
+ // the output is English, and a card that switches language mid-way reads as a glitch.
1021
+ const names = (list) => (Array.isArray(list) ? list.map((x) => x?.name_en || x?.name || x?.code || x).filter(Boolean) : [])
1022
+
1023
+ out(res.body, () => {
1024
+ console.log(vacancyHead(d))
1025
+
1026
+ const rows = detailRows([
1027
+ ['area', names(d.specializations)],
1028
+ ['grade', names(d.grades)],
1029
+ ['skills', names(d.skills)],
1030
+ ['location', [...names(d.regions), ...names(d.cities)]],
1031
+ ['posted', dateOnly(d.created_at)],
1032
+ ['page', d.url],
1033
+ ])
1034
+ if (rows.length) console.log('\n' + rows.join('\n'))
1035
+
1036
+ const text = d.description ? asText(d.description) : ''
1037
+ console.log(text ? `\n${text}\n` : '\nThis vacancy has no text on it.\n')
1038
+
1039
+ // Which of the two ways to apply this one takes, from the server's own flag. Guessing
1040
+ // it from anything else is how a card came to promise an apply that answered 422.
1041
+ console.log(d.can_apply_directly
1042
+ ? `Apply on Hirify: hirify vacancy apply ${d.slug ?? slug}`
1043
+ : `Where to apply: hirify vacancy reveal ${d.slug ?? slug} (uses 1 reveal)`)
1044
+
1045
+ console.log(res.body?.charged === false
1046
+ ? '(no vacancy open used: this one was already opened today)'
1047
+ : '(1 vacancy open used)')
1048
+ const left = count(res.body?.quota?.remaining)
1049
+ if (left !== null) console.log(`${left} ${left === 1 ? 'open' : 'opens'} left today`)
1050
+ })
1051
+ }
1052
+
1053
+ /**
1054
+ * One contact line: the address as it is, with the type only when it adds something.
1055
+ * An unknown shape is still printed as it came, because the contact is the whole point
1056
+ * of the command; an empty slot returns nothing, so no line is printed for it.
1057
+ */
1058
+ function contactLine(c) {
1059
+ if (typeof c === 'string') return c
1060
+ const value = c?.value ?? c?.url ?? c?.email ?? null
1061
+ if (value) return c?.type && c.type !== 'url' ? `${value} (${c.type})` : value
1062
+ return c === null || c === undefined ? null : JSON.stringify(c)
1063
+ }
1064
+
1065
+ async function cmdVacancyReveal(args, words) {
1066
+ const [slug] = words
1067
+ if (!slug) die('a vacancy slug is required: hirify vacancy reveal <slug>')
1068
+ const body = await callCapability('vacancies.reveal', { params: { slug } })
1069
+ const d = body?.data ?? {}
1070
+ out(body, () => {
1071
+ console.log(`company: ${d.company ?? '-'}`)
1072
+ if (d.linkedin) console.log(`linkedin: ${d.linkedin}`)
1073
+ // A contact arrives as {type, value, short_code}. People and agents want the address,
1074
+ // not its JSON: nobody unpacks a raw object by hand while reading a terminal.
1075
+ for (const c of d.contacts ?? []) {
1076
+ const line = contactLine(c)
1077
+ if (line) console.log(`contact: ${line}`)
1078
+ }
1079
+ console.log(d.charged === false
1080
+ ? '\n(no reveal used: you have revealed this vacancy before)'
1081
+ : '\n(1 reveal used)')
1082
+ const left = count(d.quota?.remaining)
1083
+ if (left !== null) console.log(`${left} ${left === 1 ? 'reveal' : 'reveals'} left`)
1084
+ })
1085
+ }
1086
+
1087
+ /**
1088
+ * Report a bug or ask for a feature. Free: it does not touch the reveal limit.
1089
+ *
1090
+ * What is required is checked here, because a missing `--body` is a fact about the command
1091
+ * and needs no round trip. How long a title may be is NOT checked here: that is the
1092
+ * server's rule, it has changed before, and a copy of it in this file is a copy that goes
1093
+ * stale and starts refusing reports the server would have taken. The server's own words
1094
+ * come back instead.
1095
+ */
1096
+ async function cmdFeedbackSend(args, words) {
1097
+ const [type, title] = words
1098
+ if (!FEEDBACK_TYPES.includes(type)) {
1099
+ die('say what kind of report this is, bug or feature:\n' +
1100
+ ' hirify feedback send bug "<title>" --body "<what happened>"\n' +
1101
+ ' hirify feedback send feature "<title>" --body "<what you need>"')
1102
+ }
1103
+
1104
+ const text = flag(args, '--body')
1105
+ const vacancy = flag(args, '--vacancy')
1106
+
1107
+ if (!title) die('a title is required: hirify feedback send ' + type + ' "<title>" --body "<text>"')
1108
+ if (!text) die('the report needs a body: add --body "<text>"')
1109
+
1110
+ const res = await callCapability('feedback.send', {
1111
+ payload: { type, title, body: text, ...(vacancy ? { vacancy_slug: vacancy } : {}) },
1112
+ allow: [201, 202, 404, 422, 429, 502, 503],
1113
+ })
1114
+
1115
+ const d = res.body?.data ?? {}
1116
+
1117
+ if (res.status === 429) {
1118
+ const wait = Number(res.retryAfter)
1119
+ die('too many reports in a short time.' +
1120
+ (Number.isFinite(wait) && wait > 0 ? ` Please try again in ${wait} seconds.` : ' Please try again a bit later.'))
1121
+ }
1122
+ if (res.status === 502) {
1123
+ // Not queued and not retried on our side, because a retry would not fix it. Say that
1124
+ // the fault is ours, so nobody rewrites a perfectly good report thinking it was them.
1125
+ die('we could not pass your report on. That is on our side, not in what you wrote.' +
1126
+ '\n Please try again a bit later.')
1127
+ }
1128
+ if (res.status === 404 || res.status === 503) {
1129
+ // 404 means this API does not have the feedback endpoint at all, 503 that it has it
1130
+ // and it is switched off. Both are the same fact for the person in front of us, and
1131
+ // "not found (404)" would send them looking for a mistake in their own command.
1132
+ die('the feedback channel is not available right now. Please try again later.')
1133
+ }
1134
+ if (res.status === 422) {
1135
+ // The server owns the lengths and it says which one is wrong, so its sentence is the
1136
+ // useful one. Ours would have to name a bound, and naming a bound we do not own is how
1137
+ // a client starts telling people a limit that moved.
1138
+ if (process.env.HIRIFY_DEBUG) console.error(`hirify: server answered 422: ${JSON.stringify(res.body)}`)
1139
+ die(serverMessage(res.body) || 'the report was not accepted. Please check the title and the text and try again.')
1140
+ }
1141
+
1142
+ // Say `number`, never `id`. The number is the one a human at Hirify recognises; the id
1143
+ // is for reading the ticket through their API, and telling a person the id would give
1144
+ // them a number nobody there can look up. There is deliberately no link to a report:
1145
+ // ticket numbers are sequential, and a public address would let anyone walk through
1146
+ // other people's complaints. And nothing writes back afterwards, so we promise nothing.
1147
+ const ticket = d.ticket?.number ?? (typeof d.ticket === 'number' ? d.ticket : null)
1148
+
1149
+ out(res.body, () => {
1150
+ if (ticket && d.ticket?.duplicate) {
1151
+ console.log(`Thank you. This matches a report we already have, number ${ticket}.`)
1152
+ } else if (ticket) {
1153
+ console.log(`Thank you. Your report was passed on as number ${ticket}.`)
1154
+ } else {
1155
+ console.log('Thank you. Your report was passed on. There is no number for it yet.')
1156
+ }
1157
+ if (d.reference) console.log(`reference: ${d.reference}`)
1158
+ })
1159
+ }
1160
+
1161
+ /** The profiles a person can apply with. Free, and the list `vacancy apply` picks from. */
1162
+ async function cmdProfileList() {
1163
+ const body = await callCapability('profiles.list')
1164
+ const list = body?.data ?? []
1165
+ out(body, () => {
1166
+ if (!list.length) {
1167
+ return console.log('You have no profiles yet. Create one on hirify.me, then you can apply.')
1168
+ }
1169
+ for (const p of list) {
1170
+ const state = [p.status, p.is_complete === false ? 'incomplete' : null].filter(Boolean).join(' · ')
1171
+ console.log(`${String(p.profile_id ?? '-').padEnd(6)} ${p.name || p.title || '(untitled)'}${state ? ` [${state}]` : ''}`)
1172
+ }
1173
+ console.log('\nApply with one: hirify vacancy apply <slug> --profile <id>')
1174
+ })
1175
+ }
1176
+
1177
+ /**
1178
+ * Send a real application, on Hirify, to a real person. This is the one command here
1179
+ * that cannot be undone, so it never guesses: if the account has several profiles and
1180
+ * none was named, the server refuses and we pass that on rather than picking one.
1181
+ */
1182
+ async function cmdVacancyApply(args, words) {
1183
+ const [slug] = words
1184
+ if (!slug) die('a vacancy slug is required: hirify vacancy apply <slug> [--profile <id>]')
1185
+
1186
+ const profile = flag(args, '--profile')
1187
+ const cover = flag(args, '--cover')
1188
+
1189
+ // A profile id is a number, and that is a fact about the flag, not a server limit. How
1190
+ // long a cover letter may be is the server's rule and is not copied here: it answers 422
1191
+ // in its own words, and those travel back untouched.
1192
+ if (profile !== null && !/^\d+$/.test(profile)) die('--profile takes a profile id, a number. See hirify profile list.')
1193
+
1194
+ const res = await callCapability('applications.apply', {
1195
+ params: { slug },
1196
+ payload: { ...(profile ? { profile_id: Number(profile) } : {}), ...(cover ? { cover_letter: cover } : {}) },
1197
+ allow: [201, 404, 422, 502],
1198
+ })
1199
+
1200
+ if (res.status === 404) die('there is no vacancy with that slug.')
1201
+ if (res.status === 502) die('the application could not be sent right now. Please try again in a minute.')
1202
+ if (res.status === 422) {
1203
+ // These come from the same rules the site applies: archived, flagged, hosted
1204
+ // elsewhere, someone else's profile. They are written to be read, so pass them on
1205
+ // instead of flattening every one of them into "something went wrong".
1206
+ die(serverMessage(res.body) || 'the application was not accepted. Please check the vacancy and the profile.')
1207
+ }
1208
+
1209
+ const d = res.body?.data ?? {}
1210
+ out(res.body, () => {
1211
+ console.log(d.application_id
1212
+ ? `Applied. Application ${d.application_id}, status ${d.status ?? 'sent'}.`
1213
+ : `Applied. Status ${d.status ?? 'sent'}.`)
1214
+ console.log('Hirify does not follow up for you: the recruiter replies where they choose to.')
1215
+ })
1216
+ }
1217
+
1218
+ /** Save a search, the same thing a person does with the filter form on the site. */
1219
+ async function cmdFeedCreate(args, words) {
1220
+ const [name] = words
1221
+ // Required is ours to check; how long is the server's, and it says so in its own words.
1222
+ if (!name) die('a name is required: hirify feed create "<name>" [--filters \'<json>\']')
1223
+
1224
+ // An empty set of criteria is legal and means "send me everything", exactly as it does
1225
+ // on the site. So the field is always sent, and only bad JSON is refused.
1226
+ let filters = {}
1227
+ const raw = flag(args, '--filters')
1228
+ if (raw) {
1229
+ try {
1230
+ filters = JSON.parse(raw)
1231
+ } catch {
1232
+ // No example criterion in the message: the shape is ours to state, the keys are not.
1233
+ die('--filters expects JSON: the same criteria the site\'s filter form produces.\n' +
1234
+ ' Their names and values: hirify filter guide')
1235
+ }
1236
+ }
1237
+
1238
+ const payload = { name, filters }
1239
+ if (args.includes('--no-telegram')) payload.notify_telegram = false
1240
+ if (args.includes('--telegram')) payload.notify_telegram = true
1241
+ const webhook = flag(args, '--webhook')
1242
+ if (webhook) {
1243
+ if (!/^\d+$/.test(webhook)) die('--webhook takes an endpoint id, a number. See hirify webhook list.')
1244
+ payload.webhook_endpoint_id = Number(webhook)
1245
+ }
1246
+
1247
+ const res = await callCapability('feeds.create', { payload, allow: [201, 422] })
1248
+ if (res.status === 422) die(serverMessage(res.body) || 'the feed was not created. Please check the criteria.')
1249
+
1250
+ out(res.body, () => printFeedState(res.body?.data ?? {}, 'Saved.'))
1251
+ }
1252
+
1253
+ /** Change where a feed is delivered, without touching what it searches for. */
1254
+ async function cmdFeedDeliver(args, words) {
1255
+ const [id] = words
1256
+ if (!id || !/^\d+$/.test(id)) die('a feed id is required: hirify feed deliver <id> [--telegram] [--webhook <id>]')
1257
+
1258
+ const payload = {}
1259
+ if (args.includes('--telegram')) payload.notify_telegram = true
1260
+ if (args.includes('--no-telegram')) payload.notify_telegram = false
1261
+ if (args.includes('--no-webhook')) payload.webhook_endpoint_id = null
1262
+ const webhook = flag(args, '--webhook')
1263
+ if (webhook) {
1264
+ if (!/^\d+$/.test(webhook)) die('--webhook takes an endpoint id, a number. See hirify webhook list.')
1265
+ payload.webhook_endpoint_id = Number(webhook)
1266
+ }
1267
+ if (!Object.keys(payload).length) {
1268
+ die('say what to change: --telegram, --no-telegram, --webhook <id> or --no-webhook.')
1269
+ }
1270
+
1271
+ const res = await callCapability('feeds.set_delivery', { params: { feed_id: id }, payload, allow: [200, 404, 422] })
1272
+ if (res.status === 404) die('there is no feed with that id. See hirify feed list.')
1273
+ if (res.status === 422) die(serverMessage(res.body) || 'the delivery settings were not accepted.')
1274
+
1275
+ out(res.body, () => printFeedState(res.body?.data ?? {}, 'Updated.'))
1276
+ }
1277
+
1278
+ function printFeedState(f, lead) {
1279
+ const where = [
1280
+ f.notify_telegram ? 'Telegram' : null,
1281
+ f.webhook_endpoint_id ? `webhook ${f.webhook_endpoint_id}` : null,
1282
+ ].filter(Boolean)
1283
+ console.log(`${lead} Feed ${f.id ?? '-'}: ${f.name || '(untitled)'}`)
1284
+ console.log(where.length ? `Delivered to: ${where.join(' and ')}` : 'Not delivered anywhere yet.')
1285
+ }
1286
+
1287
+ /** The delivery endpoints on the account. Free; creating one needs the plan. */
1288
+ async function cmdWebhookList() {
1289
+ const body = await callCapability('webhooks.list')
1290
+ const list = body?.data ?? []
1291
+ out(body, () => {
1292
+ if (!list.length) {
1293
+ return console.log('You have no delivery endpoints yet. Create one: hirify webhook create "<name>" <url>')
1294
+ }
1295
+ for (const w of list) {
1296
+ console.log(`${String(w.id ?? '-').padEnd(6)} ${w.name || '(untitled)'} ${w.url ?? '-'}${w.state ? ` [${w.state}]` : ''}`)
1297
+ }
1298
+ console.log('\nSend a feed to one: hirify feed deliver <feed id> --webhook <id>')
1299
+ })
1300
+ }
1301
+
1302
+ async function cmdWebhookCreate(args, words) {
1303
+ const [name, url] = words
1304
+ if (!name || !url) die('both a name and an address are required: hirify webhook create "<name>" <url>')
1305
+
1306
+ const res = await callCapability('webhooks.create', { payload: { name, url }, allow: [201, 403, 422] })
1307
+ if (res.status === 403) die(serverMessage(res.body) || 'creating a delivery endpoint is not available on this account.')
1308
+ if (res.status === 422) die(serverMessage(res.body) || 'that address was not accepted.')
1309
+
1310
+ const d = res.body?.data ?? {}
1311
+ out(res.body, () => {
1312
+ console.log(`Created. Endpoint ${d.id ?? '-'}: ${d.name || '(untitled)'} ${d.url ?? ''}`.trimEnd())
1313
+ // The secret is shown once and never again, so it gets its own line and a warning
1314
+ // rather than sitting inside a sentence someone may scroll past.
1315
+ if (d.secret) {
1316
+ console.log(`\nsecret: ${d.secret}`)
1317
+ console.log('Store it now. It is shown once and it signs every delivery.')
1318
+ }
1319
+ })
1320
+ }
1321
+
1322
+ /** The fallback for CI and servers with no browser. The normal way in is `hirify login`. */
1323
+ function cmdAuth(args, words) {
1324
+ const [key] = words
1325
+ if (!key) {
1326
+ die('to sign in through the browser: `hirify login`.\n' +
1327
+ ' With a key (CI, servers): `hirify auth <key>`, from hirify.me/account/api-access')
1328
+ }
1329
+ writeSession({ kind: 'key', access_token: key })
1330
+ if (existsSync(LEGACY_KEY_FILE)) rmSync(LEGACY_KEY_FILE)
1331
+ console.log(`Key saved to ${AUTH_FILE}`)
1332
+ }
1333
+
1334
+ /**
1335
+ * The first command anyone runs, and the only long text the CLI carries. It is prose, not a
1336
+ * list: `--help` already lists the commands, and a list does not tell you the order to use
1337
+ * them in or which of them spends something.
1338
+ *
1339
+ * Written for two readers at once, because that is who is there: the agent doing the work,
1340
+ * and the person watching it. Neither needs a different version of the truth.
1341
+ *
1342
+ * No network call. This has to work before anyone has signed in.
1343
+ */
1344
+ const INTRO = `hirify - job search for AI agents
1345
+
1346
+ Hirify is a job board. This CLI is how an agent works it for someone: the same vacancies, the
1347
+ same saved filters and the same account they have at hirify.me.
1348
+
1349
+ Signing in
1350
+ hirify login opens a browser and needs a person at the screen, so ask for it rather than
1351
+ running it. On a server with no browser there is a key instead: hirify auth <key>, from
1352
+ hirify.me/account/api-access.
1353
+
1354
+ Start with what the account already has
1355
+ Most people who use Hirify have saved a filter or two on the site. Those are feeds, and they
1356
+ are the best place to start, because someone has already said in them what they want.
1357
+
1358
+ hirify account show the plan and allowances
1359
+ hirify feed list what this account has saved
1360
+ hirify feed show 31 the vacancies in one of them
1361
+
1362
+ When no feed fits, search the whole board. Search takes a phrase, and any criterion the
1363
+ site's own filter form can express, written as an option:
1364
+
1365
+ hirify vacancy search "senior go"
1366
+ hirify vacancy search "senior go" --page 2
1367
+ hirify vacancy search "senior go" --<criterion> <value>
1368
+
1369
+ The criteria are the server's, not this CLI's, and nothing here names one on purpose: a
1370
+ name written into this text would be a copy, and copies go stale quietly. Ask instead:
1371
+
1372
+ hirify filter guide
1373
+
1374
+ It prints what search can filter on and what the values are, written by the server from
1375
+ the same source the site searches with. An option is sent on under the name you gave it;
1376
+ give one twice and the values are joined, the way the site sends a filter with several
1377
+ values.
1378
+
1379
+ Read before you spend anything
1380
+ A card is a headline: title, company, terms. Fit is decided in the text.
1381
+
1382
+ hirify vacancy read senior-go-engineer
1383
+
1384
+ This prints the whole vacancy, and it also says which of the two ways to apply this one
1385
+ takes, so you do not have to work that out or find out from a refusal.
1386
+
1387
+ What costs what
1388
+ Lists and searches are free.
1389
+ Reading one vacancy in full spends one of the Agent API's daily vacancy opens. There are
1390
+ many of them, and reading a vacancy again the same day costs nothing. Read as much as you
1391
+ need to.
1392
+ Revealing where to apply spends 1 reveal, and reveals are the scarce one. Protect that
1393
+ number: shortlist by reading, then reveal only the ones worth applying to.
1394
+ Applying has a daily allowance of its own. It is not the free step it looks like.
1395
+ hirify account show has all three, and it is the only place they are current.
1396
+
1397
+ Where to apply
1398
+ hirify vacancy reveal senior-go-engineer
1399
+
1400
+ Gives the company, its LinkedIn page when we know it, and the address to send the
1401
+ application to. Revealing the same vacancy again returns the same thing and costs nothing.
1402
+
1403
+ Applying
1404
+ Most vacancies here came from somewhere else: company pages, Telegram channels, other
1405
+ boards. For those, reveal brings back the address and the person applies themselves.
1406
+ Vacancies hosted on Hirify can be applied to from here:
1407
+
1408
+ hirify profile list
1409
+ hirify vacancy apply senior-go-engineer --profile 4 --cover "..."
1410
+
1411
+ An application reaches a real person and cannot be recalled. Ask first, every time, and
1412
+ show what you are about to send. Nothing follows up afterwards: the recruiter replies
1413
+ where they choose to.
1414
+
1415
+ Two more things
1416
+ A saved search can be created from here and delivered to Telegram or to a server of yours:
1417
+ hirify feed create, hirify feed deliver, hirify webhook list.
1418
+ Something broken or missing: hirify feedback send bug "<title>" --body "<what happened>". It
1419
+ reaches the team and costs nothing.
1420
+
1421
+ If a command you need is not here
1422
+ hirify api call <capability> invokes any capability Hirify lists in its manifest and prints
1423
+ the answer as it comes back, so a capability without its own command is a detour and not a
1424
+ dead end. Inputs go in --data as a JSON object. Prefer the commands above where one fits:
1425
+ they say what a thing costs and what a refusal means, and this one cannot.
1426
+
1427
+ If you are an agent
1428
+ Install the working rules once: npx skills add hirifyme/hirify-cli. They cover the order
1429
+ above, what needs the person's permission before you do it, and what each refusal means.
1430
+
1431
+ Every command takes --json. The full list of them: hirify --help`
1432
+
1433
+ async function cmdIntro() {
1434
+ // The intro is release-approved text published on the public agent well-known, so it can be
1435
+ // refreshed without shipping a new CLI. It needs no sign-in and no manifest. When Hirify
1436
+ // cannot be reached, or publishes none, the built-in text below stands in - so `hirify intro`
1437
+ // and the sign-in help it carries work before login and with no network.
1438
+ const { intro } = await agentDiscovery()
1439
+ console.log(typeof intro === 'string' && intro ? intro : INTRO)
1440
+ }
1441
+
1442
+ // The fields the compact vacancy view keeps from a full summary card: the shortlist signal a
1443
+ // scan needs, without the detail `vacancy read` adds. This is the selection the context budget
1444
+ // is measured against - twenty of them serialize small - so a page of results does not spend an
1445
+ // agent's context on fields it does not act on here. `--fields` narrows it; `--json` keeps the
1446
+ // whole card.
1447
+ const CARD_FIELDS = ['slug', 'title', 'company_masked', 'remote_type', 'work_format', 'english_level', 'salary']
1448
+
1449
+ // The projections whose compact selection is the vacancy card. `api call` renders these with
1450
+ // CARD_FIELDS by default, the same shortlist view `vacancy search` prints.
1451
+ const KNOWN_CARD_PROJECTIONS = new Set(['vacancy.summary'])
1452
+
1453
+ // The field names asked for with --fields, or null for "every field the answer carries".
1454
+ const fieldList = (args) => {
1455
+ const raw = flag(args, '--fields')
1456
+ return raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null
1457
+ }
1458
+
1459
+ // One item reduced to the fields asked for, keeping only the ones the server actually sent, so
1460
+ // a selection never manufactures a field the API did not return.
1461
+ function selectFields(item, fields) {
1462
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return item
1463
+ const keys = (fields ?? Object.keys(item)).filter((k) => Object.hasOwn(item, k))
1464
+ return Object.fromEntries(keys.map((k) => [k, item[k]]))
1465
+ }
1466
+
1467
+ // A value on one line: an address or a number as it is, a list joined, an object as compact
1468
+ // JSON. Nothing is dropped; a missing value reads as a dash rather than the word undefined.
1469
+ const scalarText = (v) => {
1470
+ if (v === null || v === undefined) return '-'
1471
+ if (Array.isArray(v)) return v.map(scalarText).filter((s) => s !== '').join(', ')
1472
+ if (typeof v === 'object') return JSON.stringify(v)
1473
+ return String(v)
1474
+ }
1475
+
1476
+ /**
1477
+ * A compact view of a capability answer the CLI has no bespoke command for. It prints the
1478
+ * fields the server returned, one per line, item by item, so `hirify api call` reads without
1479
+ * --json. `--fields` narrows to the names asked for, and a known card projection is narrowed to
1480
+ * the shortlist selection by default. It never invents a field, and --json still hands over the
1481
+ * whole canonical answer.
1482
+ */
1483
+ function renderGeneric(body, fields = null) {
1484
+ const payload = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body
1485
+ const items = Array.isArray(payload) ? payload : [payload]
1486
+ const blocks = []
1487
+ for (const item of items) {
1488
+ const picked = selectFields(item, fields)
1489
+ if (picked === null || typeof picked !== 'object' || Array.isArray(picked)) {
1490
+ const text = picked === null || picked === undefined ? '' : String(picked)
1491
+ if (text) blocks.push(text)
1492
+ continue
1493
+ }
1494
+ const rows = Object.entries(picked).map(([k, v]) => `${k}: ${scalarText(v)}`)
1495
+ if (rows.length) blocks.push(rows.join('\n'))
1496
+ }
1497
+ const text = blocks.join('\n\n')
1498
+ if (text) console.log(text)
1499
+ }
1500
+
1501
+ // Which method takes a request body, so a `--data` input lands in the body rather than the
1502
+ // query string when the manifest does not say where it goes.
1503
+ const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
1504
+
1505
+ /**
1506
+ * The generic call. Every other command is a shape we chose for one job; this one takes a
1507
+ * capability by its id, looks it up in the manifest, and sends the request the manifest
1508
+ * describes, so a capability this CLI has no command for is a detour rather than a dead end -
1509
+ * and a capability the server adds later is reachable from here with no change to this file.
1510
+ *
1511
+ * Inputs travel in `--data` as one JSON object. Each value goes where the manifest says it
1512
+ * belongs: into the path, the query string, or the body. The answer is printed as it arrived,
1513
+ * refusals included: a sentence of ours in place of the server's own reply is exactly what
1514
+ * this command exists to get out of the way, and that also means it cannot say what a call
1515
+ * costs, which the named commands can.
1516
+ */
1517
+ async function cmdApiCall(args, words) {
1518
+ const [id] = words
1519
+ if (!id) {
1520
+ die('a capability id is required: hirify api call <capability>\n' +
1521
+ ' The ids are the ones Hirify lists in its manifest; the named commands cover the\n' +
1522
+ ' common ones, and this reaches the rest. Inputs go in --data as a JSON object.')
1523
+ }
1524
+
1525
+ // Read the inputs before reaching the server: a typo in --data is the caller's, and no
1526
+ // manifest is needed to see it.
1527
+ const data = flag(args, '--data')
1528
+ let inputs = {}
1529
+ if (data) {
1530
+ try {
1531
+ inputs = JSON.parse(data)
1532
+ } catch {
1533
+ die('--data expects JSON, for example --data \'{"slug":"senior-go-engineer"}\'')
1534
+ }
1535
+ }
1536
+ if (inputs === null || typeof inputs !== 'object' || Array.isArray(inputs)) {
1537
+ die('--data expects a JSON object of inputs, for example --data \'{"page":2}\'')
1538
+ }
1539
+
1540
+ const cap = await resolveCapability(id)
1541
+
1542
+ // Where each input belongs, from the manifest. An input the manifest does not place goes to
1543
+ // the body on a writing method and to the query otherwise, which is where an unplaced input
1544
+ // most usefully lands.
1545
+ const locations = (cap.inputs && cap.inputs.locations) || {}
1546
+ const writes = BODY_METHODS.has(cap.method.toUpperCase())
1547
+ const params = {}
1548
+ const query = new URLSearchParams()
1549
+ const body = {}
1550
+ let hasBody = false
1551
+
1552
+ for (const [key, value] of Object.entries(inputs)) {
1553
+ const where = locations[key] || (writes ? 'body' : 'query')
1554
+ if (where === 'path') {
1555
+ params[key] = value
1556
+ } else if (where === 'body') {
1557
+ body[key] = value
1558
+ hasBody = true
1559
+ } else {
1560
+ query.set(key, Array.isArray(value)
1561
+ ? value.join(',')
1562
+ : value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value))
1563
+ }
1564
+ }
1565
+
1566
+ // Invoke it. The capability is looked up again here, which revalidates the manifest with
1567
+ // its ETag and reuses the cached document on a 304 rather than downloading it twice.
1568
+ const res = await callCapability(id, { params, query, payload: hasBody ? body : null, raw: true })
1569
+
1570
+ // Under --json, or when the server refused, or when the answer is not JSON, the canonical
1571
+ // body goes through untouched: --json is the raw contract, a refusal carries the server's own
1572
+ // machine-readable words, and a non-JSON answer has no fields to select. Otherwise the answer
1573
+ // is rendered compactly - a known card projection narrowed to its shortlist selection, any
1574
+ // other shape by its own fields - so `api call` reads without --json and stays small in an
1575
+ // agent's context. --fields narrows the selection further.
1576
+ const raw = process.argv.includes('--json') || res.status >= 400 || res.body === null
1577
+ if (raw) {
1578
+ const text = res.body === null ? res.text.trim() : JSON.stringify(res.body, null, 2)
1579
+ if (text) console.log(text)
1580
+ } else {
1581
+ const fields = fieldList(args) ?? (KNOWN_CARD_PROJECTIONS.has(cap.output_projection) ? CARD_FIELDS : null)
1582
+ renderGeneric(res.body, fields)
1583
+ }
1584
+ // On stderr, so that stdout stays the server's answer and nothing else.
1585
+ if (res.status >= 400) die(`the server answered ${res.status}. The answer is above.`)
1586
+ }
1587
+
1588
+ /**
1589
+ * The filter vocabulary, printed as the server wrote it.
1590
+ *
1591
+ * `guide`, not `show`, and the reason is what comes back: this is not a list of keys but
1592
+ * the method the site's own filter generator works by, and calling it `show` would promise
1593
+ * a list. `eco mail inbox` is the same shape in the same grammar.
1594
+ *
1595
+ * Nothing about filters is written down in this file, and nothing should be. The endpoint
1596
+ * derives the list from the source the site searches with, so it cannot drift; a copy here
1597
+ * would start drifting the day it was written.
1598
+ */
1599
+ async function cmdFilterGuide() {
1600
+ const res = await callCapability('filters.guide', { allow: [200, 404] })
1601
+
1602
+ // Older servers do not serve it. Say that, rather than "not found (404)", which reads as
1603
+ // a mistake in the command. And do not offer a substitute: there is nothing here that
1604
+ // knows the filters, and inventing advice about them is the whole problem this fixes.
1605
+ if (res.status === 404) {
1606
+ die('this Hirify server does not serve the filter guide yet.\n' +
1607
+ ' Search still takes every criterion the site\'s filter form can express, but\n' +
1608
+ ' their names have to come from someone who knows them.')
1609
+ }
1610
+
1611
+ const guide = typeof res.body?.guide === 'string' ? res.body.guide.trim() : ''
1612
+ if (!guide) die('the server answered without a guide. Please try again in a minute.')
1613
+
1614
+ out(res.body, () => console.log(guide))
1615
+ }
1616
+
1617
+ /** The skill ships through skills.sh now: one command installs it into every harness. */
1618
+ function cmdSkill() {
1619
+ console.log('The rules for your agent install with one command:\n\n npx skills add hirifyme/hirify-cli\n')
1620
+ console.log('It puts them where your agent reads them: Claude Code, Codex, Cursor, OpenCode and others.')
1621
+ }
1622
+
1623
+ // ── router ─────────────────────────────────────────────────────────────────
1624
+ // Commands that are not something you do to a thing. Signing in is not an operation on a
1625
+ // vacancy or a feed, and `intro` is the first thing anyone runs, so it stays one word.
1626
+ const PLAIN = {
1627
+ intro: cmdIntro, skill: cmdSkill,
1628
+ login: cmdLogin, logout: cmdLogout, auth: cmdAuth,
1629
+ }
1630
+
1631
+ // Everything else is a noun and a verb, the grammar the rest of our tools already use.
1632
+ // The noun is the thing you are working with, the verb is what you are doing to it.
1633
+ const NOUNS = {
1634
+ account: { show: cmdAccountShow },
1635
+ vacancy: { search: cmdVacancySearch, read: cmdVacancyRead, reveal: cmdVacancyReveal, apply: cmdVacancyApply },
1636
+ feed: { list: cmdFeedList, show: cmdFeedShow, create: cmdFeedCreate, deliver: cmdFeedDeliver },
1637
+ profile: { list: cmdProfileList },
1638
+ webhook: { list: cmdWebhookList, create: cmdWebhookCreate },
1639
+ feedback: { send: cmdFeedbackSend },
1640
+ filter: { guide: cmdFilterGuide },
1641
+ api: { call: cmdApiCall },
1642
+ }
1643
+
1644
+ const [noun, ...args] = process.argv.slice(2)
1645
+
1646
+ if (!noun || noun === '--help' || noun === '-h' || noun === 'help') { console.log(HELP); process.exit(EXIT_OK) }
1647
+
1648
+ // `Object.hasOwn`, not a plain lookup: every object inherits `toString` and `constructor`,
1649
+ // and `hirify toString` used to find one and run it, which exits 0 having done nothing.
1650
+ if (Object.hasOwn(PLAIN, noun)) {
1651
+ await PLAIN[noun](args, positional(args))
1652
+ } else if (Object.hasOwn(NOUNS, noun)) {
1653
+ const verbs = NOUNS[noun]
1654
+ const known = `hirify ${noun} takes a verb: ${Object.keys(verbs).join(', ')}`
1655
+ if (args.includes('--help') || args.includes('-h')) { console.log(known); process.exit(EXIT_OK) }
1656
+
1657
+ // The verb is read from the positionals, so an option written in front of it cannot
1658
+ // stand in for it: `hirify feed --json list` is still the list.
1659
+ const [verb, ...rest] = positional(args)
1660
+ if (!verb) die(known)
1661
+ if (!Object.hasOwn(verbs, verb)) die(`hirify ${noun} has no verb "${verb}".\n ${known}`)
1662
+ await verbs[verb](args, rest)
1663
+ } else {
1664
+ die(`unknown command: ${noun}\n\n${HELP}`)
1665
+ }