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