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