hirify-cli 0.4.6 → 0.5.0

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