hirify-cli 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -51
- package/bin/hirify.js +3 -1883
- package/bin/lib/api.js +130 -0
- package/bin/lib/auth.js +113 -0
- package/bin/lib/browser.js +34 -0
- package/bin/lib/cli.js +126 -0
- package/bin/lib/commands.js +827 -0
- package/bin/lib/config.js +27 -0
- package/bin/lib/errors.js +20 -0
- package/bin/lib/http.js +113 -0
- package/bin/lib/input.js +39 -0
- package/bin/lib/loopback.js +65 -0
- package/bin/lib/main.js +103 -0
- package/bin/lib/messages.js +62 -0
- package/bin/lib/store.js +118 -0
- package/bin/lib/update.js +171 -0
- package/bin/open-browser.js +2 -13
- package/npm-shrinkwrap.json +349 -0
- package/package.json +17 -4
- package/skills/hirify/SKILL.md +51 -76
- package/skills/hirify/reference.md +20 -13
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { CliError } from './errors.js'
|
|
2
|
+
import { flag, options, CLI_ONLY } from './cli.js'
|
|
3
|
+
import { serverMessage } from './messages.js'
|
|
4
|
+
export const CARD_FIELDS = ['slug', 'title', 'company_masked', 'remote_type', 'work_format', 'english_level', 'salary']
|
|
5
|
+
export function createCommands({ api, output, config, signal, event, fields }) {
|
|
6
|
+
const { callCapability, resolveCapability, agentDiscovery } = api
|
|
7
|
+
const console = output.console
|
|
8
|
+
const VERSION = config.version
|
|
9
|
+
const FEEDBACK_TYPES = ['bug', 'feature']
|
|
10
|
+
const count = v => typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
11
|
+
const die = (message, exitCode = 1) => { throw new CliError(api.lastError?.code || 'command_failed', message, { ...api.lastError, exitCode }) }
|
|
12
|
+
const out = (data, render) => {
|
|
13
|
+
if (output.jsonMode) output.json(data)
|
|
14
|
+
else { if (fields) renderGeneric(data, fields.split(',').map(x => x.trim()).filter(Boolean)); else render(); const notice = data?.meta?.notice?.message; if (typeof notice === 'string' && notice.trim()) console.log('\nNotice: ' + notice.trim()) }
|
|
15
|
+
}
|
|
16
|
+
async function cmdAccountShow() {
|
|
17
|
+
const body = await callCapability('account.status')
|
|
18
|
+
const d = body?.data ?? {}
|
|
19
|
+
const left = count(d?.quota?.reveal?.remaining)
|
|
20
|
+
// Reading one vacancy in full has its own daily allowance, so it gets its own line.
|
|
21
|
+
// Without it an agent planning a session can only find the wall by hitting it.
|
|
22
|
+
const reads = count(d?.quota?.read?.remaining)
|
|
23
|
+
// So does applying, and this line was missing while the skill told agents that applying
|
|
24
|
+
// spends nothing. The server has reported `quota.apply` all along; dropping a budget the
|
|
25
|
+
// server publishes is the same error as inventing one, pointing the other way.
|
|
26
|
+
const applies = count(d?.quota?.apply?.remaining)
|
|
27
|
+
const u = d?.usage?.reveal ?? {}
|
|
28
|
+
// Whatever the server sent, and nothing else: a period it left out is simply not shown.
|
|
29
|
+
const spent = [[u.today, 'today'], [u.last_7d, 'in 7d'], [u.last_30d, 'in 30d']]
|
|
30
|
+
.filter(([n]) => count(n) !== null)
|
|
31
|
+
.map(([n, when]) => `${n} ${when}`)
|
|
32
|
+
out(body, () => {
|
|
33
|
+
console.log(`plan: ${d?.plan ?? '-'}`)
|
|
34
|
+
// How many are left, and no denominator. Reveals are not a fraction of a number the
|
|
35
|
+
// person has, so `N of M` would be inventing the M.
|
|
36
|
+
console.log(`reveals: ${left === null ? '-' : `${left} left`}`)
|
|
37
|
+
if (reads !== null) console.log(`opens: ${reads} left today`)
|
|
38
|
+
if (applies !== null) console.log(`applies: ${applies} left today`)
|
|
39
|
+
if (spent.length) console.log(`usage: ${spent.join(' · ')}`)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function cmdFeedList() {
|
|
44
|
+
const body = await callCapability('feeds.list')
|
|
45
|
+
const list = body?.data ?? []
|
|
46
|
+
out(body, () => {
|
|
47
|
+
if (!list.length) return console.log('You have no feeds yet. Save a filter on hirify.me and it becomes a feed.')
|
|
48
|
+
for (const f of list) {
|
|
49
|
+
const off = f.is_active === false ? ' (off)' : ''
|
|
50
|
+
console.log(`${String(f.id ?? '-').padEnd(6)} ${f.name || '(untitled)'}${off}`)
|
|
51
|
+
}
|
|
52
|
+
console.log(`\nVacancies from a feed: hirify feed show <id>`)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The head of a vacancy, printed the same whether it comes from a list or from `read`.
|
|
58
|
+
* Fields come straight from the API's vacancy resource: a card carries no contacts by
|
|
59
|
+
* design, and for premium companies the name arrives masked.
|
|
60
|
+
*
|
|
61
|
+
* One function on purpose. A card that looks different depending on which command drew
|
|
62
|
+
* it is a card an agent has to learn twice.
|
|
63
|
+
*/
|
|
64
|
+
function vacancyHead(v) {
|
|
65
|
+
// `company_masked` is a FLAG meaning "the name is hidden until you reveal", not the
|
|
66
|
+
// name itself. It used to be printed as it came, and cards ended up saying "- true".
|
|
67
|
+
const company = v.company || (v.company_masked ? 'company hidden' : '-')
|
|
68
|
+
// Some of these arrive as arrays: live data has `work_format: []` next to
|
|
69
|
+
// `employee_type: ['employment']`. An empty array is truthy, so it used to survive
|
|
70
|
+
// the filter and print as a blank slot between two separators: "[usa · · b2]".
|
|
71
|
+
const label = (x) => (Array.isArray(x) ? x.filter(Boolean).join('/') : x)
|
|
72
|
+
const bits = [v.remote_type, v.work_format, v.employee_type, v.english_level].map(label).filter(Boolean)
|
|
73
|
+
if (v.salary && (v.salary.min || v.salary.max)) {
|
|
74
|
+
const { min, max, currency } = v.salary
|
|
75
|
+
bits.push([min, max].filter(Boolean).join('-') + (currency ? ` ${currency}` : ''))
|
|
76
|
+
}
|
|
77
|
+
if (v.verified) bits.push('verified')
|
|
78
|
+
// The slug is the handle every other command takes, so it leads the card. A card
|
|
79
|
+
// without one is still worth printing for its title, but the first line has to say
|
|
80
|
+
// that there is nothing to copy, not print the word `undefined` where a slug goes.
|
|
81
|
+
return `${v.slug ?? '-'}\n ${v.title || '-'} · ${company}${bits.length ? `\n [${bits.join(' · ')}]` : ''}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function printVacancies(list, meta) {
|
|
85
|
+
if (!list.length) return console.log('Nothing found.')
|
|
86
|
+
for (const v of list) console.log(vacancyHead(v))
|
|
87
|
+
|
|
88
|
+
const page = count(meta?.page)
|
|
89
|
+
const perPage = count(meta?.per_page)
|
|
90
|
+
const lastPage = count(meta?.last_page)
|
|
91
|
+
const total = count(meta?.total)
|
|
92
|
+
|
|
93
|
+
// `of N` is printed only when N is larger than what came back, because only then is it a
|
|
94
|
+
// total and not a restatement of the page. The agent search endpoint currently answers
|
|
95
|
+
// with the size of the page it just returned, and "Showing 50 of 50" reads as "that is
|
|
96
|
+
// the whole board", which sends an agent away from vacancies that are there. The moment
|
|
97
|
+
// the server sends a real total, this prints it, with no change here.
|
|
98
|
+
const of = total !== null && total > list.length ? ` of ${total}` : ''
|
|
99
|
+
// `of L` once there is more than one page, on the last page too: "page 4 of 4" is how
|
|
100
|
+
// you know you have reached the end rather than lost the rest.
|
|
101
|
+
const where = page === null ? '' : `, page ${page}${lastPage !== null && lastPage > 1 ? ` of ${lastPage}` : ''}`
|
|
102
|
+
console.log(`\nShowing ${list.length}${of}${where}.`)
|
|
103
|
+
// The order the product asks for: read what looks right, reveal only what fits.
|
|
104
|
+
console.log('Read one: hirify vacancy read <slug>')
|
|
105
|
+
console.log('Where to apply: hirify vacancy reveal <slug> (uses 1 reveal)')
|
|
106
|
+
|
|
107
|
+
// A full page is the only honest sign that there may be another one while `last_page`
|
|
108
|
+
// says otherwise. Offering the next page costs nothing if it turns out to be empty.
|
|
109
|
+
const more = lastPage !== null && page !== null && lastPage > page
|
|
110
|
+
? true
|
|
111
|
+
: perPage !== null && list.length >= perPage
|
|
112
|
+
if (more && page !== null) console.log(`More: add --page ${page + 1}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The vacancies in one saved feed, using the criteria the feed already holds. */
|
|
116
|
+
async function cmdFeedShow(args, words) {
|
|
117
|
+
const [id] = words
|
|
118
|
+
if (!id) die('a feed id is required: hirify feed show <id> (list them with hirify feed list)')
|
|
119
|
+
|
|
120
|
+
// A feed already carries its own criteria, so only the two that say which slice of it to
|
|
121
|
+
// return travel from here. Without `--page` the second page of a feed was unreachable.
|
|
122
|
+
const p = new URLSearchParams()
|
|
123
|
+
const limit = flag(args, '--limit'); if (limit) p.set('per_page', limit)
|
|
124
|
+
const page = flag(args, '--page'); if (page) p.set('page', page)
|
|
125
|
+
|
|
126
|
+
const body = await callCapability('feeds.vacancies', { params: { feed_id: id }, query: p })
|
|
127
|
+
out(body, () => printVacancies(body?.data ?? [], body?.meta))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Search the board. The words are the phrase to look for; every option is passed on to the
|
|
132
|
+
* API as it was written.
|
|
133
|
+
*
|
|
134
|
+
* The CLI keeps no list of filters on purpose. The endpoint accepts the same criteria the
|
|
135
|
+
* site's own filter form produces, and the site can express all of them: a CLI that names
|
|
136
|
+
* them one flag at a time decides what is expressible, and it decided wrong for a long time.
|
|
137
|
+
* So every option travels by one rule, and a criterion added on the server works from here
|
|
138
|
+
* the day it ships.
|
|
139
|
+
*
|
|
140
|
+
* This is also why no example here names a criterion. `hirify filter guide` is the
|
|
141
|
+
* vocabulary, the server writes it, and an example frozen in this file would be a second
|
|
142
|
+
* answer to the same question, going stale at its own pace.
|
|
143
|
+
*/
|
|
144
|
+
async function cmdVacancySearch(args, words) {
|
|
145
|
+
const p = new URLSearchParams()
|
|
146
|
+
|
|
147
|
+
// The phrase first, so an explicit `--search` still wins if someone writes both.
|
|
148
|
+
//
|
|
149
|
+
// `search` and `per_page` are the only two parameter names this file is allowed to know,
|
|
150
|
+
// and they are structural rather than vocabulary: this command exists to put the words on
|
|
151
|
+
// `search`, and `--limit` has to land somewhere. Every other name belongs to the server
|
|
152
|
+
// and is fetched with `hirify filter guide`. Adding a third name here is the regression.
|
|
153
|
+
const phrase = words.join(' ')
|
|
154
|
+
if (phrase) p.set('search', phrase)
|
|
155
|
+
|
|
156
|
+
for (const [name, value] of options(args)) {
|
|
157
|
+
if (CLI_ONLY.has(name)) continue
|
|
158
|
+
// `--limit` is what this CLI has always called it. The API calls it `per_page`, and
|
|
159
|
+
// that is the name the server publishes, so both arrive at the same parameter.
|
|
160
|
+
p.set(name === 'limit' ? 'per_page' : name, value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const body = await callCapability('vacancies.search', { query: p })
|
|
164
|
+
out(body, () => printVacancies(body?.data ?? [], body?.meta))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The description as the site shows it, turned into something a terminal can print. The
|
|
169
|
+
* API sends it as HTML (`description_format`), so the tags have to go somewhere: a block
|
|
170
|
+
* ends with a line break and a list item starts with a dash. Nothing is dropped or
|
|
171
|
+
* shortened here, and `--json` hands over the original untouched.
|
|
172
|
+
*/
|
|
173
|
+
function asText(html) {
|
|
174
|
+
return String(html)
|
|
175
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
176
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
177
|
+
.replace(/<li\b[^>]*>/gi, '- ')
|
|
178
|
+
.replace(/<\/(p|div|h[1-6]|li|tr|blockquote|ul|ol|table)>/gi, '\n')
|
|
179
|
+
.replace(/<[^>]+>/g, '')
|
|
180
|
+
.replace(/ /gi, ' ')
|
|
181
|
+
.replace(/</gi, '<')
|
|
182
|
+
.replace(/>/gi, '>')
|
|
183
|
+
.replace(/"/gi, '"')
|
|
184
|
+
.replace(/�?39;|'/gi, "'")
|
|
185
|
+
// Last, or `&lt;` would come out as `<` rather than as the `<` somebody wrote.
|
|
186
|
+
.replace(/&/gi, '&')
|
|
187
|
+
.replace(/[ \t]+$/gm, '')
|
|
188
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
189
|
+
.trim()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A timestamp as the day it names. The hour a vacancy was imported tells nobody anything. */
|
|
193
|
+
const dateOnly = (v) => (typeof v === 'string' && v.length >= 10 ? v.slice(0, 10) : null)
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The rows under the head, each one printed only when the server sent something for it.
|
|
197
|
+
* They are collected rather than printed straight, so a vacancy that happens to carry
|
|
198
|
+
* none of them does not get a blank line standing in for the block.
|
|
199
|
+
*/
|
|
200
|
+
function detailRows(pairs) {
|
|
201
|
+
return pairs
|
|
202
|
+
.map(([label, value]) => [label, Array.isArray(value) ? value.filter(Boolean).join(' · ') : value])
|
|
203
|
+
.filter(([, text]) => text)
|
|
204
|
+
.map(([label, text]) => `${(label + ':').padEnd(10)}${text}`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One vacancy in full, with the text a person reads on the site. This is the command an
|
|
209
|
+
* agent shortlists with, so it is deliberately the cheap one: it counts against the day's
|
|
210
|
+
* vacancy opens, which is a generous allowance, and not against the reveal budget.
|
|
211
|
+
*
|
|
212
|
+
* The same vacancy read twice in a day costs nothing the second time.
|
|
213
|
+
*/
|
|
214
|
+
async function cmdVacancyRead(args, words) {
|
|
215
|
+
const [slug] = words
|
|
216
|
+
if (!slug) {
|
|
217
|
+
die('a vacancy slug is required: hirify vacancy read <slug>' +
|
|
218
|
+
'\n Slugs come from hirify vacancy search or hirify feed show.')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const res = await callCapability('vacancies.read', { params: { slug }, allow: [200, 404] })
|
|
222
|
+
if (res.status === 404) die('there is no vacancy with that slug.')
|
|
223
|
+
|
|
224
|
+
const d = res.body?.data ?? {}
|
|
225
|
+
// Codes, names and plain strings all arrive in these lists. English first: the rest of
|
|
226
|
+
// the output is English, and a card that switches language mid-way reads as a glitch.
|
|
227
|
+
const names = (list) => (Array.isArray(list) ? list.map((x) => x?.name_en || x?.name || x?.code || x).filter(Boolean) : [])
|
|
228
|
+
|
|
229
|
+
out(res.body, () => {
|
|
230
|
+
console.log(vacancyHead(d))
|
|
231
|
+
|
|
232
|
+
const rows = detailRows([
|
|
233
|
+
['area', names(d.specializations)],
|
|
234
|
+
['grade', names(d.grades)],
|
|
235
|
+
['skills', names(d.skills)],
|
|
236
|
+
['location', [...names(d.regions), ...names(d.cities)]],
|
|
237
|
+
['posted', dateOnly(d.created_at)],
|
|
238
|
+
['page', d.url],
|
|
239
|
+
])
|
|
240
|
+
if (rows.length) console.log('\n' + rows.join('\n'))
|
|
241
|
+
|
|
242
|
+
const text = d.description ? asText(d.description) : ''
|
|
243
|
+
console.log(text ? `\n${text}\n` : '\nThis vacancy has no text on it.\n')
|
|
244
|
+
|
|
245
|
+
// Which of the two ways to apply this one takes, from the server's own flag. Guessing
|
|
246
|
+
// it from anything else is how a card came to promise an apply that answered 422.
|
|
247
|
+
console.log(d.can_apply_directly
|
|
248
|
+
? `Apply on Hirify: hirify vacancy apply ${d.slug ?? slug}`
|
|
249
|
+
: `Where to apply: hirify vacancy reveal ${d.slug ?? slug} (uses 1 reveal)`)
|
|
250
|
+
|
|
251
|
+
console.log(res.body?.charged === false
|
|
252
|
+
? '(no vacancy open used: this one was already opened today)'
|
|
253
|
+
: '(1 vacancy open used)')
|
|
254
|
+
const left = count(res.body?.quota?.remaining)
|
|
255
|
+
if (left !== null) console.log(`${left} ${left === 1 ? 'open' : 'opens'} left today`)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One contact line: the address as it is, with the type only when it adds something.
|
|
261
|
+
* An unknown shape is still printed as it came, because the contact is the whole point
|
|
262
|
+
* of the command; an empty slot returns nothing, so no line is printed for it.
|
|
263
|
+
*/
|
|
264
|
+
function contactLine(c) {
|
|
265
|
+
if (typeof c === 'string') return c
|
|
266
|
+
const value = c?.value ?? c?.url ?? c?.email ?? null
|
|
267
|
+
if (value) return c?.type && c.type !== 'url' ? `${value} (${c.type})` : value
|
|
268
|
+
return c === null || c === undefined ? null : JSON.stringify(c)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function cmdVacancyReveal(args, words) {
|
|
272
|
+
const [slug] = words
|
|
273
|
+
if (!slug) die('a vacancy slug is required: hirify vacancy reveal <slug>')
|
|
274
|
+
const body = await callCapability('vacancies.reveal', { params: { slug } })
|
|
275
|
+
const d = body?.data ?? {}
|
|
276
|
+
out(body, () => {
|
|
277
|
+
console.log(`company: ${d.company ?? '-'}`)
|
|
278
|
+
if (d.linkedin) console.log(`linkedin: ${d.linkedin}`)
|
|
279
|
+
// A contact arrives as {type, value, short_code}. People and agents want the address,
|
|
280
|
+
// not its JSON: nobody unpacks a raw object by hand while reading a terminal.
|
|
281
|
+
for (const c of d.contacts ?? []) {
|
|
282
|
+
const line = contactLine(c)
|
|
283
|
+
if (line) console.log(`contact: ${line}`)
|
|
284
|
+
}
|
|
285
|
+
console.log(d.charged === false
|
|
286
|
+
? '\n(no reveal used: you have revealed this vacancy before)'
|
|
287
|
+
: '\n(1 reveal used)')
|
|
288
|
+
const left = count(d.quota?.remaining)
|
|
289
|
+
if (left !== null) console.log(`${left} ${left === 1 ? 'reveal' : 'reveals'} left`)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Report a bug or ask for a feature. Free: it does not touch the reveal limit.
|
|
295
|
+
*
|
|
296
|
+
* What is required is checked here, because a missing `--body` is a fact about the command
|
|
297
|
+
* and needs no round trip. How long a title may be is NOT checked here: that is the
|
|
298
|
+
* server's rule, it has changed before, and a copy of it in this file is a copy that goes
|
|
299
|
+
* stale and starts refusing reports the server would have taken. The server's own words
|
|
300
|
+
* come back instead.
|
|
301
|
+
*/
|
|
302
|
+
async function cmdFeedbackSend(args, words) {
|
|
303
|
+
const [type, title] = words
|
|
304
|
+
if (!FEEDBACK_TYPES.includes(type)) {
|
|
305
|
+
die('say what kind of report this is, bug or feature:\n' +
|
|
306
|
+
' hirify feedback send bug "<title>" --body "<what happened>"\n' +
|
|
307
|
+
' hirify feedback send feature "<title>" --body "<what you need>"')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const text = flag(args, '--body')
|
|
311
|
+
const vacancy = flag(args, '--vacancy')
|
|
312
|
+
|
|
313
|
+
if (!title) die('a title is required: hirify feedback send ' + type + ' "<title>" --body "<text>"')
|
|
314
|
+
if (!text) die('the report needs a body: add --body "<text>"')
|
|
315
|
+
|
|
316
|
+
const res = await callCapability('feedback.send', {
|
|
317
|
+
payload: { type, title, body: text, ...(vacancy ? { vacancy_slug: vacancy } : {}), ...(flag(args, '--idempotency-key') ? { idempotency_key: flag(args, '--idempotency-key') } : {}) },
|
|
318
|
+
allow: [201, 202, 404, 422, 429, 502, 503],
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const d = res.body?.data ?? {}
|
|
322
|
+
|
|
323
|
+
if (res.status === 429) {
|
|
324
|
+
const wait = Number(res.retryAfter)
|
|
325
|
+
die('too many reports in a short time.' +
|
|
326
|
+
(Number.isFinite(wait) && wait > 0 ? ` Please try again in ${wait} seconds.` : ' Please try again a bit later.'))
|
|
327
|
+
}
|
|
328
|
+
if (res.status === 502) {
|
|
329
|
+
// Not queued and not retried on our side, because a retry would not fix it. Say that
|
|
330
|
+
// the fault is ours, so nobody rewrites a perfectly good report thinking it was them.
|
|
331
|
+
throw new CliError('outcome_unknown', 'The report result could not be confirmed. Check its status before trying again.', { status: 502 })
|
|
332
|
+
}
|
|
333
|
+
if (res.status === 404 || res.status === 503) {
|
|
334
|
+
// 404 means this API does not have the feedback endpoint at all, 503 that it has it
|
|
335
|
+
// and it is switched off. Both are the same fact for the person in front of us, and
|
|
336
|
+
// "not found (404)" would send them looking for a mistake in their own command.
|
|
337
|
+
die('the feedback channel is not available right now. Please try again later.')
|
|
338
|
+
}
|
|
339
|
+
if (res.status === 422) {
|
|
340
|
+
// The server owns the lengths and it says which one is wrong, so its sentence is the
|
|
341
|
+
// useful one. Ours would have to name a bound, and naming a bound we do not own is how
|
|
342
|
+
// a client starts telling people a limit that moved.
|
|
343
|
+
die(serverMessage(res.body) || 'the report was not accepted. Please check the title and the text and try again.')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Say `number`, never `id`. The number is the one a human at Hirify recognises; the id
|
|
347
|
+
// is for reading the ticket through their API, and telling a person the id would give
|
|
348
|
+
// them a number nobody there can look up. There is deliberately no link to a report:
|
|
349
|
+
// ticket numbers are sequential, and a public address would let anyone walk through
|
|
350
|
+
// other people's complaints. And nothing writes back afterwards, so we promise nothing.
|
|
351
|
+
const ticket = d.ticket?.number ?? (typeof d.ticket === 'number' ? d.ticket : null)
|
|
352
|
+
|
|
353
|
+
out(res.body, () => {
|
|
354
|
+
if (d.status === 'queued') {
|
|
355
|
+
console.log('Thank you. Your report was received and is waiting to be sent.')
|
|
356
|
+
} else if (ticket && d.ticket?.duplicate) {
|
|
357
|
+
console.log(`Thank you. This matches a report we already have, number ${ticket}.`)
|
|
358
|
+
} else if (ticket) {
|
|
359
|
+
console.log(`Thank you. Your report was passed on as number ${ticket}.`)
|
|
360
|
+
} else {
|
|
361
|
+
console.log('Thank you. Your report was passed on. There is no number for it yet.')
|
|
362
|
+
}
|
|
363
|
+
if (d.reference) console.log(`reference: ${d.reference}`)
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The profiles a person can apply with. Free, and the list `vacancy apply` picks from. */
|
|
368
|
+
async function cmdProfileList() {
|
|
369
|
+
const body = await callCapability('profiles.list')
|
|
370
|
+
const list = body?.data ?? []
|
|
371
|
+
out(body, () => {
|
|
372
|
+
if (!list.length) {
|
|
373
|
+
return console.log('You have no profiles yet. Create one on hirify.me, then you can apply.')
|
|
374
|
+
}
|
|
375
|
+
for (const p of list) {
|
|
376
|
+
const state = [p.status, p.is_complete === false ? 'incomplete' : null].filter(Boolean).join(' · ')
|
|
377
|
+
console.log(`${String(p.profile_id ?? '-').padEnd(6)} ${p.name || p.title || '(untitled)'}${state ? ` [${state}]` : ''}`)
|
|
378
|
+
}
|
|
379
|
+
console.log('\nApply with one: hirify vacancy apply <slug> --profile <id>')
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Send a real application, on Hirify, to a real person. This is the one command here
|
|
385
|
+
* that cannot be undone, so it never guesses: if the account has several profiles and
|
|
386
|
+
* none was named, the server refuses and we pass that on rather than picking one.
|
|
387
|
+
*/
|
|
388
|
+
async function cmdVacancyApply(args, words) {
|
|
389
|
+
const [slug] = words
|
|
390
|
+
if (!slug) die('a vacancy slug is required: hirify vacancy apply <slug> [--profile <id>]')
|
|
391
|
+
|
|
392
|
+
const profile = flag(args, '--profile')
|
|
393
|
+
const cover = flag(args, '--cover')
|
|
394
|
+
|
|
395
|
+
// A profile id is a number, and that is a fact about the flag, not a server limit. How
|
|
396
|
+
// long a cover letter may be is the server's rule and is not copied here: it answers 422
|
|
397
|
+
// in its own words, and those travel back untouched.
|
|
398
|
+
if (profile !== null && !/^\d+$/.test(profile)) die('--profile takes a profile id, a number. See hirify profile list.')
|
|
399
|
+
|
|
400
|
+
const res = await callCapability('applications.apply', {
|
|
401
|
+
params: { slug },
|
|
402
|
+
payload: { ...(profile ? { profile_id: Number(profile) } : {}), ...(cover ? { cover_letter: cover } : {}) },
|
|
403
|
+
allow: [201, 404, 422, 502],
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
if (res.status === 404) die('there is no vacancy with that slug.')
|
|
407
|
+
if (res.status === 502) throw new CliError('outcome_unknown', 'The application result could not be confirmed. Check your applications before trying again.', { status: 502 })
|
|
408
|
+
if (res.status === 422) {
|
|
409
|
+
// These come from the same rules the site applies: archived, flagged, hosted
|
|
410
|
+
// elsewhere, someone else's profile. They are written to be read, so pass them on
|
|
411
|
+
// instead of flattening every one of them into "something went wrong".
|
|
412
|
+
die(serverMessage(res.body) || 'the application was not accepted. Please check the vacancy and the profile.')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const d = res.body?.data ?? {}
|
|
416
|
+
out(res.body, () => {
|
|
417
|
+
console.log(d.application_id
|
|
418
|
+
? `Applied. Application ${d.application_id}, status ${d.status ?? 'sent'}.`
|
|
419
|
+
: `Applied. Status ${d.status ?? 'sent'}.`)
|
|
420
|
+
console.log('Hirify does not follow up for you: the recruiter replies where they choose to.')
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Save a search, the same thing a person does with the filter form on the site. */
|
|
425
|
+
async function cmdFeedCreate(args, words) {
|
|
426
|
+
const [name] = words
|
|
427
|
+
// Required is ours to check; how long is the server's, and it says so in its own words.
|
|
428
|
+
if (!name) die('a name is required: hirify feed create "<name>" [--filters \'<json>\']')
|
|
429
|
+
|
|
430
|
+
// An empty set of criteria is legal and means "send me everything", exactly as it does
|
|
431
|
+
// on the site. So the field is always sent, and only bad JSON is refused.
|
|
432
|
+
let filters = {}
|
|
433
|
+
const raw = flag(args, '--filters')
|
|
434
|
+
if (raw) {
|
|
435
|
+
try {
|
|
436
|
+
filters = JSON.parse(raw)
|
|
437
|
+
} catch {
|
|
438
|
+
// No example criterion in the message: the shape is ours to state, the keys are not.
|
|
439
|
+
die('--filters expects JSON: the same criteria the site\'s filter form produces.\n' +
|
|
440
|
+
' Their names and values: hirify filter guide')
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const payload = { name, filters, notify_telegram: false }
|
|
445
|
+
if (args.includes('--no-telegram')) payload.notify_telegram = false
|
|
446
|
+
if (args.includes('--telegram')) payload.notify_telegram = true
|
|
447
|
+
const webhook = flag(args, '--webhook')
|
|
448
|
+
if (webhook) {
|
|
449
|
+
if (!/^\d+$/.test(webhook)) die('--webhook takes an endpoint id, a number. See hirify webhook list.')
|
|
450
|
+
payload.webhook_endpoint_id = Number(webhook)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const res = await callCapability('feeds.create', { payload, allow: [200, 201, 422] })
|
|
454
|
+
if (res.status === 422) die(serverMessage(res.body) || 'the feed was not created. Please check the criteria.')
|
|
455
|
+
|
|
456
|
+
out(res.body, () => {
|
|
457
|
+
const feed = res.body?.data ?? {}
|
|
458
|
+
printFeedState(feed, 'Saved.')
|
|
459
|
+
if (!feed.notify_telegram && !feed.webhook_endpoint_id) {
|
|
460
|
+
console.log(`To receive new matches in Telegram: hirify feed deliver ${feed.id ?? '<id>'} --telegram`)
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Change where a feed is delivered, without touching what it searches for. */
|
|
466
|
+
async function cmdFeedDeliver(args, words) {
|
|
467
|
+
const [id] = words
|
|
468
|
+
if (!id || !/^\d+$/.test(id)) die('a feed id is required: hirify feed deliver <id> [--telegram|--no-telegram] [--webhook <id>|--no-webhook]')
|
|
469
|
+
|
|
470
|
+
const payload = {}
|
|
471
|
+
if (args.includes('--telegram')) payload.notify_telegram = true
|
|
472
|
+
if (args.includes('--no-telegram')) payload.notify_telegram = false
|
|
473
|
+
if (args.includes('--no-webhook')) payload.webhook_endpoint_id = null
|
|
474
|
+
const webhook = flag(args, '--webhook')
|
|
475
|
+
if (webhook) {
|
|
476
|
+
if (!/^\d+$/.test(webhook)) die('--webhook takes an endpoint id, a number. See hirify webhook list.')
|
|
477
|
+
payload.webhook_endpoint_id = Number(webhook)
|
|
478
|
+
}
|
|
479
|
+
if (!Object.keys(payload).length) {
|
|
480
|
+
die('say what to change: --telegram, --no-telegram, --webhook <id> or --no-webhook.')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const res = await callCapability('feeds.set_delivery', { params: { feed_id: id }, payload, allow: [200, 404, 422] })
|
|
484
|
+
if (res.status === 404) die('there is no feed with that id. See hirify feed list.')
|
|
485
|
+
if (res.status === 422) die(serverMessage(res.body) || 'the delivery settings were not accepted.')
|
|
486
|
+
|
|
487
|
+
out(res.body, () => printFeedState(res.body?.data ?? {}, 'Updated.'))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function printFeedState(f, lead) {
|
|
491
|
+
const where = [
|
|
492
|
+
f.notify_telegram ? 'Telegram' : null,
|
|
493
|
+
f.webhook_endpoint_id ? `webhook ${f.webhook_endpoint_id}` : null,
|
|
494
|
+
].filter(Boolean)
|
|
495
|
+
console.log(`${lead} Feed ${f.id ?? '-'}: ${f.name || '(untitled)'}`)
|
|
496
|
+
console.log(where.length ? `Delivered to: ${where.join(' and ')}` : 'Delivery is off.')
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The delivery endpoints on the account. Free; creating one needs the plan. */
|
|
500
|
+
async function cmdWebhookList() {
|
|
501
|
+
const body = await callCapability('webhooks.list')
|
|
502
|
+
const list = body?.data ?? []
|
|
503
|
+
out(body, () => {
|
|
504
|
+
if (!list.length) {
|
|
505
|
+
return console.log('You have no delivery endpoints yet. Create one: hirify webhook create "<name>" <url>')
|
|
506
|
+
}
|
|
507
|
+
for (const w of list) {
|
|
508
|
+
console.log(`${String(w.id ?? '-').padEnd(6)} ${w.name || '(untitled)'} ${w.url ?? '-'}${w.state ? ` [${w.state}]` : ''}`)
|
|
509
|
+
}
|
|
510
|
+
console.log('\nSend a feed to one: hirify feed deliver <feed id> --webhook <id>')
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function cmdWebhookCreate(args, words) {
|
|
515
|
+
const [name, url] = words
|
|
516
|
+
if (!name || !url) die('both a name and an address are required: hirify webhook create "<name>" <url>')
|
|
517
|
+
|
|
518
|
+
const res = await callCapability('webhooks.create', { payload: { name, url }, allow: [201, 403, 422] })
|
|
519
|
+
if (res.status === 403) die(serverMessage(res.body) || 'creating a delivery endpoint is not available on this account.')
|
|
520
|
+
if (res.status === 422) die(serverMessage(res.body) || 'that address was not accepted.')
|
|
521
|
+
|
|
522
|
+
const d = res.body?.data ?? {}
|
|
523
|
+
out(res.body, () => {
|
|
524
|
+
console.log(`Created. Endpoint ${d.id ?? '-'}: ${d.name || '(untitled)'} ${d.url ?? ''}`.trimEnd())
|
|
525
|
+
// The secret is shown once and never again, so it gets its own line and a warning
|
|
526
|
+
// rather than sitting inside a sentence someone may scroll past.
|
|
527
|
+
if (d.secret) {
|
|
528
|
+
console.log(`\nsecret: ${d.secret}`)
|
|
529
|
+
console.log('Store it now. It is shown once and it signs every delivery.')
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const INTRO = `hirify - job search for AI agents
|
|
535
|
+
|
|
536
|
+
Hirify is a job board. This CLI is how an agent works it for someone: the same vacancies, the
|
|
537
|
+
same saved filters and the same account they have at hirify.me.
|
|
538
|
+
|
|
539
|
+
Signing in
|
|
540
|
+
hirify login prints a link and tries the browser when available. A person confirms access.
|
|
541
|
+
Manual sign-in: hirify login --no-browser. For automation, use HIRIFY_KEY or hirify auth --stdin; get a key at
|
|
542
|
+
hirify.me/account/api-access.
|
|
543
|
+
|
|
544
|
+
Start with what the account already has
|
|
545
|
+
Most people who use Hirify have saved a filter or two on the site. Those are feeds, and they
|
|
546
|
+
are the best place to start, because someone has already said in them what they want.
|
|
547
|
+
|
|
548
|
+
hirify account show the plan and allowances
|
|
549
|
+
hirify feed list what this account has saved
|
|
550
|
+
hirify feed show 31 the vacancies in one of them
|
|
551
|
+
|
|
552
|
+
When no feed fits, search the whole board. Search takes a phrase, and any criterion the
|
|
553
|
+
site's own filter form can express, written as an option:
|
|
554
|
+
|
|
555
|
+
hirify vacancy search "senior go"
|
|
556
|
+
hirify vacancy search "senior go" --page 2
|
|
557
|
+
hirify vacancy search "senior go" --<criterion> <value>
|
|
558
|
+
|
|
559
|
+
The criteria are the server's, not this CLI's, and nothing here names one on purpose: a
|
|
560
|
+
name written into this text would be a copy, and copies go stale quietly. Ask instead:
|
|
561
|
+
|
|
562
|
+
hirify filter guide
|
|
563
|
+
|
|
564
|
+
It prints what search can filter on and what the values are, written by the server from
|
|
565
|
+
the same source the site searches with. An option is sent on under the name you gave it;
|
|
566
|
+
give one twice and the values are joined, the way the site sends a filter with several
|
|
567
|
+
values.
|
|
568
|
+
|
|
569
|
+
Read before you spend anything
|
|
570
|
+
A card is a headline: title, company, terms. Fit is decided in the text.
|
|
571
|
+
|
|
572
|
+
hirify vacancy read senior-go-engineer
|
|
573
|
+
|
|
574
|
+
This prints the whole vacancy, and it also says which of the two ways to apply this one
|
|
575
|
+
takes, so you do not have to work that out or find out from a refusal.
|
|
576
|
+
|
|
577
|
+
What costs what
|
|
578
|
+
Lists and searches are free.
|
|
579
|
+
Reading one vacancy in full spends one of the Agent API's daily vacancy opens. There are
|
|
580
|
+
many of them, and reading a vacancy again the same day costs nothing. Read as much as you
|
|
581
|
+
need to.
|
|
582
|
+
Revealing where to apply spends 1 reveal, and reveals are the scarce one. Protect that
|
|
583
|
+
number: shortlist by reading, then reveal only the ones worth applying to.
|
|
584
|
+
Applying has a daily allowance of its own. It is not the free step it looks like.
|
|
585
|
+
hirify account show has all three, and it is the only place they are current.
|
|
586
|
+
|
|
587
|
+
Where to apply
|
|
588
|
+
hirify vacancy reveal senior-go-engineer
|
|
589
|
+
|
|
590
|
+
Gives the company, its LinkedIn page when we know it, and the address to send the
|
|
591
|
+
application to. Revealing the same vacancy again returns the same thing and costs nothing.
|
|
592
|
+
|
|
593
|
+
Applying
|
|
594
|
+
Most vacancies here came from somewhere else: company pages, Telegram channels, other
|
|
595
|
+
boards. For those, reveal brings back the address, and the application is made there rather
|
|
596
|
+
than through Hirify.
|
|
597
|
+
Vacancies hosted on Hirify can be applied to from here:
|
|
598
|
+
|
|
599
|
+
hirify profile list
|
|
600
|
+
hirify vacancy apply senior-go-engineer --profile 4 --cover "..."
|
|
601
|
+
|
|
602
|
+
An application reaches a real person and cannot be recalled. Ask first, every time, and
|
|
603
|
+
show what you are about to send. Nothing follows up afterwards: the recruiter replies
|
|
604
|
+
where they choose to.
|
|
605
|
+
|
|
606
|
+
Two more things
|
|
607
|
+
A saved search can be created from here and delivered to Telegram or to a server of yours:
|
|
608
|
+
hirify feed create, hirify feed deliver, hirify webhook list.
|
|
609
|
+
Something broken or missing: hirify feedback send bug "<title>" --body "<what happened>". It
|
|
610
|
+
reaches the team and costs nothing.
|
|
611
|
+
|
|
612
|
+
If a command you need is not here
|
|
613
|
+
hirify api call <capability> invokes any capability Hirify lists in its manifest and prints
|
|
614
|
+
the answer as it comes back, so a capability without its own command is a detour and not a
|
|
615
|
+
dead end. Inputs go in --data as a JSON object. Prefer the commands above where one fits:
|
|
616
|
+
they say what a thing costs and what a refusal means, and this one cannot.
|
|
617
|
+
|
|
618
|
+
If you are an agent
|
|
619
|
+
Install the working rules once: npx skills add hirifyme/hirify-cli. They cover the order
|
|
620
|
+
above, what needs the person's permission before you do it, and what each refusal means.
|
|
621
|
+
|
|
622
|
+
Use --json for structured results and --error-format=json for structured errors.
|
|
623
|
+
The full list of commands: hirify --help`
|
|
624
|
+
|
|
625
|
+
async function cmdIntro() {
|
|
626
|
+
// The intro is release-approved text published on the public agent well-known, so it can be
|
|
627
|
+
// refreshed without shipping a new CLI. It needs no sign-in and no manifest. When Hirify
|
|
628
|
+
// cannot be reached, or publishes none, the built-in text below stands in - so `hirify intro`
|
|
629
|
+
// and the sign-in help it carries work before login and with no network.
|
|
630
|
+
let intro
|
|
631
|
+
try { intro = (await agentDiscovery()).intro } catch (error) { if (signal.aborted) throw error; event('intro', { code: error.code }) }
|
|
632
|
+
const text = typeof intro === 'string' && intro ? intro : INTRO
|
|
633
|
+
if (output.jsonMode) output.json({ intro: text }); else console.log(text)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// The fields the compact vacancy view keeps from a full summary card: the shortlist signal a
|
|
637
|
+
// scan needs, without the detail `vacancy read` adds. This is the selection the context budget
|
|
638
|
+
// is measured against - twenty of them serialize small - so a page of results does not spend an
|
|
639
|
+
// agent's context on fields it does not act on here. `--fields` narrows it; `--json` keeps the
|
|
640
|
+
// whole card.
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
// The projections whose compact selection is the vacancy card. `api call` renders these with
|
|
644
|
+
// CARD_FIELDS by default, the same shortlist view `vacancy search` prints.
|
|
645
|
+
const KNOWN_CARD_PROJECTIONS = new Set(['vacancy.summary'])
|
|
646
|
+
|
|
647
|
+
// The field names asked for with --fields, or null for "every field the answer carries".
|
|
648
|
+
const fieldList = (args) => {
|
|
649
|
+
const raw = flag(args, '--fields')
|
|
650
|
+
return raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : null
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// One item reduced to the fields asked for, keeping only the ones the server actually sent, so
|
|
654
|
+
// a selection never manufactures a field the API did not return.
|
|
655
|
+
function selectFields(item, fields) {
|
|
656
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return item
|
|
657
|
+
const keys = (fields ?? Object.keys(item)).filter((k) => Object.hasOwn(item, k))
|
|
658
|
+
return Object.fromEntries(keys.map((k) => [k, item[k]]))
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// A value on one line: an address or a number as it is, a list joined, an object as compact
|
|
662
|
+
// JSON. Nothing is dropped; a missing value reads as a dash rather than the word undefined.
|
|
663
|
+
const scalarText = (v) => {
|
|
664
|
+
if (v === null || v === undefined) return '-'
|
|
665
|
+
if (Array.isArray(v)) return v.map(scalarText).filter((s) => s !== '').join(', ')
|
|
666
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
667
|
+
return String(v)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* A compact view of a capability answer the CLI has no bespoke command for. It prints the
|
|
672
|
+
* fields the server returned, one per line, item by item, so `hirify api call` reads without
|
|
673
|
+
* --json. `--fields` narrows to the names asked for, and a known card projection is narrowed to
|
|
674
|
+
* the shortlist selection by default. It never invents a field, and --json still hands over the
|
|
675
|
+
* whole canonical answer.
|
|
676
|
+
*/
|
|
677
|
+
function renderGeneric(body, fields = null) {
|
|
678
|
+
const payload = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body
|
|
679
|
+
const items = Array.isArray(payload) ? payload : [payload]
|
|
680
|
+
const blocks = []
|
|
681
|
+
for (const item of items) {
|
|
682
|
+
const picked = selectFields(item, fields)
|
|
683
|
+
if (picked === null || typeof picked !== 'object' || Array.isArray(picked)) {
|
|
684
|
+
const text = picked === null || picked === undefined ? '' : String(picked)
|
|
685
|
+
if (text) blocks.push(text)
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
const rows = Object.entries(picked).map(([k, v]) => `${k}: ${scalarText(v)}`)
|
|
689
|
+
if (rows.length) blocks.push(rows.join('\n'))
|
|
690
|
+
}
|
|
691
|
+
const text = blocks.join('\n\n')
|
|
692
|
+
if (text) console.log(text)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Which method takes a request body, so a `--data` input lands in the body rather than the
|
|
696
|
+
// query string when the manifest does not say where it goes.
|
|
697
|
+
const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The generic call. Every other command is a shape we chose for one job; this one takes a
|
|
701
|
+
* capability by its id, looks it up in the manifest, and sends the request the manifest
|
|
702
|
+
* describes, so a capability this CLI has no command for is a detour rather than a dead end -
|
|
703
|
+
* and a capability the server adds later is reachable from here with no change to this file.
|
|
704
|
+
*
|
|
705
|
+
* Inputs travel in `--data` as one JSON object. Each value goes where the manifest says it
|
|
706
|
+
* belongs: into the path, the query string, or the body. The answer is printed as it arrived,
|
|
707
|
+
* refusals included: a sentence of ours in place of the server's own reply is exactly what
|
|
708
|
+
* this command exists to get out of the way, and that also means it cannot say what a call
|
|
709
|
+
* costs, which the named commands can.
|
|
710
|
+
*/
|
|
711
|
+
async function cmdApiCall(args, words) {
|
|
712
|
+
const [id] = words
|
|
713
|
+
if (!id) {
|
|
714
|
+
die('a capability id is required: hirify api call <capability>\n' +
|
|
715
|
+
' The ids are the ones Hirify lists in its manifest; the named commands cover the\n' +
|
|
716
|
+
' common ones, and this reaches the rest. Inputs go in --data as a JSON object.')
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Read the inputs before reaching the server: a typo in --data is the caller's, and no
|
|
720
|
+
// manifest is needed to see it.
|
|
721
|
+
const data = flag(args, '--data')
|
|
722
|
+
let inputs = {}
|
|
723
|
+
if (data) {
|
|
724
|
+
try {
|
|
725
|
+
inputs = JSON.parse(data)
|
|
726
|
+
} catch {
|
|
727
|
+
die('--data expects JSON, for example --data \'{"slug":"senior-go-engineer"}\'')
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (inputs === null || typeof inputs !== 'object' || Array.isArray(inputs)) {
|
|
731
|
+
die('--data expects a JSON object of inputs, for example --data \'{"page":2}\'')
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const cap = await resolveCapability(id)
|
|
735
|
+
|
|
736
|
+
// Where each input belongs, from the manifest. An input the manifest does not place goes to
|
|
737
|
+
// the body on a writing method and to the query otherwise, which is where an unplaced input
|
|
738
|
+
// most usefully lands.
|
|
739
|
+
const locations = (cap.inputs && cap.inputs.locations) || {}
|
|
740
|
+
const writes = BODY_METHODS.has(cap.method.toUpperCase())
|
|
741
|
+
const params = Object.create(null)
|
|
742
|
+
const query = new URLSearchParams()
|
|
743
|
+
const body = Object.create(null)
|
|
744
|
+
let hasBody = false
|
|
745
|
+
|
|
746
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
747
|
+
const where = Object.hasOwn(locations, key) ? locations[key] : (writes ? 'body' : 'query')
|
|
748
|
+
if (where === 'path') {
|
|
749
|
+
params[key] = value
|
|
750
|
+
} else if (where === 'body') {
|
|
751
|
+
body[key] = value
|
|
752
|
+
hasBody = true
|
|
753
|
+
} else {
|
|
754
|
+
query.set(key, Array.isArray(value)
|
|
755
|
+
? value.join(',')
|
|
756
|
+
: value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value))
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// The immutable catalogue resolves this ID to the same description throughout the command.
|
|
761
|
+
const res = await callCapability(id, { params, query, payload: hasBody ? body : null, raw: true })
|
|
762
|
+
|
|
763
|
+
// Under --json, or when the server refused, or when the answer is not JSON, the canonical
|
|
764
|
+
// body goes through untouched: --json is the raw contract, a refusal carries the server's own
|
|
765
|
+
// machine-readable words, and a non-JSON answer has no fields to select. Otherwise the answer
|
|
766
|
+
// is rendered compactly - a known card projection narrowed to its shortlist selection, any
|
|
767
|
+
// other shape by its own fields - so `api call` reads without --json and stays small in an
|
|
768
|
+
// agent's context. --fields narrows the selection further.
|
|
769
|
+
const raw = output.jsonMode || res.status >= 400 || res.body === null
|
|
770
|
+
if (raw && !(output.errorJSON && res.status >= 400)) {
|
|
771
|
+
if (res.body !== null) output.json(res.body)
|
|
772
|
+
else if (res.text.trim()) console.log(res.text.trim())
|
|
773
|
+
} else if (res.status < 400) {
|
|
774
|
+
const fields = fieldList(args) ?? (KNOWN_CARD_PROJECTIONS.has(cap.output_projection) ? CARD_FIELDS : null)
|
|
775
|
+
renderGeneric(res.body, fields)
|
|
776
|
+
}
|
|
777
|
+
// On stderr, so that stdout stays the server's answer and nothing else.
|
|
778
|
+
if (res.status >= 400) throw new CliError(res.error.code, `the server answered ${res.status}. The answer is above.`, res.error)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The filter vocabulary, printed as the server wrote it.
|
|
783
|
+
*
|
|
784
|
+
* `guide`, not `show`, and the reason is what comes back: this is not a list of keys but
|
|
785
|
+
* the method the site's own filter generator works by, and calling it `show` would promise
|
|
786
|
+
* a list.
|
|
787
|
+
*
|
|
788
|
+
* Nothing about filters is written down in this file, and nothing should be. The endpoint
|
|
789
|
+
* derives the list from the source the site searches with, so it cannot drift; a copy here
|
|
790
|
+
* would start drifting the day it was written.
|
|
791
|
+
*/
|
|
792
|
+
async function cmdFilterGuide() {
|
|
793
|
+
const res = await callCapability('filters.guide', { allow: [200, 404] })
|
|
794
|
+
|
|
795
|
+
// Older servers do not serve it. Say that, rather than "not found (404)", which reads as
|
|
796
|
+
// a mistake in the command. And do not offer a substitute: there is nothing here that
|
|
797
|
+
// knows the filters, and inventing advice about them is the whole problem this fixes.
|
|
798
|
+
if (res.status === 404) {
|
|
799
|
+
die('this Hirify server does not serve the filter guide yet.\n' +
|
|
800
|
+
' Search still takes every criterion the site\'s filter form can express, but\n' +
|
|
801
|
+
' their names have to come from someone who knows them.')
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const guide = typeof res.body?.data?.guide === 'string' ? res.body.data.guide.trim() : ''
|
|
805
|
+
if (!guide) die('the server answered without a guide. Please try again in a minute.')
|
|
806
|
+
|
|
807
|
+
out(res.body, () => console.log(guide))
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** The skill ships through skills.sh now: one command installs it into every harness. */
|
|
811
|
+
function cmdSkill() {
|
|
812
|
+
console.log('The rules for your agent install with one command:\n\n npx skills add hirifyme/hirify-cli\n')
|
|
813
|
+
console.log('It puts them where your agent reads them: Claude Code, Codex, Cursor, OpenCode and others.')
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
return {
|
|
818
|
+
intro: cmdIntro, skill: cmdSkill,
|
|
819
|
+
'account show': cmdAccountShow,
|
|
820
|
+
'vacancy search': cmdVacancySearch, 'vacancy read': cmdVacancyRead, 'vacancy reveal': cmdVacancyReveal, 'vacancy apply': cmdVacancyApply,
|
|
821
|
+
'feed list': cmdFeedList, 'feed show': cmdFeedShow, 'feed create': cmdFeedCreate, 'feed deliver': cmdFeedDeliver,
|
|
822
|
+
'profile list': cmdProfileList, 'webhook list': cmdWebhookList, 'webhook create': cmdWebhookCreate,
|
|
823
|
+
'feedback send': cmdFeedbackSend, 'filter guide': cmdFilterGuide, 'api call': cmdApiCall,
|
|
824
|
+
'capabilities list': async () => output.json(await api.loadManifest()),
|
|
825
|
+
'capabilities show': async (_, words) => output.json(await api.resolveCapability(words[0])),
|
|
826
|
+
}
|
|
827
|
+
}
|