hirify-cli 0.5.3 → 0.6.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 +20 -8
- package/bin/lib/api.js +2 -2
- package/bin/lib/auth.js +1 -1
- package/bin/lib/browser.js +6 -1
- package/bin/lib/cli.js +5 -3
- package/bin/lib/commands.js +60 -18
- package/bin/lib/main.js +36 -3
- package/bin/lib/setup.js +102 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/skills/hirify/SKILL.md +12 -11
- package/skills/hirify/reference.md +8 -4
package/README.md
CHANGED
|
@@ -8,16 +8,26 @@ The npm package is **hirify-cli**; the executable is **hirify**.
|
|
|
8
8
|
Node 18 or newer is required. Node 22 and 24 are the primary test lanes; 18 and 20 remain compatibility lanes.
|
|
9
9
|
Windows, macOS and Linux use the same npm artifact. Native desktop browser acceptance is a separate release check.
|
|
10
10
|
|
|
11
|
+
```sh
|
|
12
|
+
npx -y hirify-cli@latest init
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
One command installs the CLI globally, installs the skill for the agents found on this computer
|
|
16
|
+
(Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Antigravity) and signs you in through the browser.
|
|
17
|
+
Run it yourself or let your agent run it. `--no-install` and `--no-login` skip a step.
|
|
18
|
+
|
|
19
|
+
The same steps one by one:
|
|
20
|
+
|
|
11
21
|
```sh
|
|
12
22
|
npm install -g hirify-cli
|
|
13
|
-
hirify
|
|
14
|
-
|
|
23
|
+
hirify skill
|
|
24
|
+
hirify login
|
|
15
25
|
```
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
The skill is the agent's working rules. It comes inside the package, so it always matches the CLI;
|
|
28
|
+
`npx skills add hirifyme/hirify-cli` installs the same skill through skills.sh.
|
|
18
29
|
Without a global install, use `npx -y hirify-cli <command>`. npm may cache the package;
|
|
19
30
|
use `npx -y hirify-cli@<version>` when the version must be reproducible.
|
|
20
|
-
The skill supplies the agent's working rules; it does not install the executable.
|
|
21
31
|
|
|
22
32
|
## Sign in
|
|
23
33
|
|
|
@@ -27,15 +37,16 @@ hirify auth status --json
|
|
|
27
37
|
```
|
|
28
38
|
|
|
29
39
|
Login prints a link first and tries the default browser when the terminal and environment permit it.
|
|
30
|
-
A launcher result does not prove that a browser opened. Confirm access yourself;
|
|
31
|
-
you to do this. The callback listens only on a loopback IP, on a temporary port. PKCE and state
|
|
40
|
+
A launcher result does not prove that a browser opened. Confirm access yourself; an agent that
|
|
41
|
+
starts the sign-in asks you to do this. The callback listens only on a loopback IP, on a temporary port. PKCE and state
|
|
32
42
|
protect the exchange. The CLI reports success after access has been saved, independently of the account summary.
|
|
33
43
|
|
|
34
44
|
| Environment | Behavior |
|
|
35
45
|
|---|---|
|
|
36
46
|
| Local desktop terminal | Prints the link, tries the browser, waits for confirmation |
|
|
37
47
|
| Linux without a display | Prints the link; does not promise or try a graphical browser |
|
|
38
|
-
|
|
|
48
|
+
| Agent shell on a local desktop | Prints the link, tries the browser, waits for confirmation |
|
|
49
|
+
| No terminal in CI, or no terminal and no display | Fails promptly if a new browser sign-in is needed; use a key or explicit manual login |
|
|
39
50
|
| SSH | Requires explicit manual login and a forwarded fixed callback port, or a key |
|
|
40
51
|
| Container | Use a key, or manual login only if the browser can reach the container's loopback callback |
|
|
41
52
|
| WSL | Uses the maintained browser launcher when local and interactive; verify Windows-to-WSL loopback on your setup |
|
|
@@ -70,6 +81,7 @@ hirify vacancy read <slug>
|
|
|
70
81
|
hirify vacancy reveal <slug>
|
|
71
82
|
hirify vacancy hide <slug>...
|
|
72
83
|
hirify company hide "<name>"
|
|
84
|
+
hirify hidden list
|
|
73
85
|
hirify profile list
|
|
74
86
|
hirify vacancy apply <slug> --profile <id> --cover-file cover.txt
|
|
75
87
|
```
|
|
@@ -79,7 +91,7 @@ have a separate allowance. `hirify account show` supplies the current values. Sh
|
|
|
79
91
|
read to assess fit, then reveal what fits. Applying sends a real application and cannot be recalled:
|
|
80
92
|
get the person's approval first. External vacancies are applied to through the destination from `reveal`.
|
|
81
93
|
Hiding a vacancy or a company is free and has no limit: it stops coming back in search and feeds,
|
|
82
|
-
here and on the website. `--undo` brings it back.
|
|
94
|
+
here and on the website. `--undo` brings it back; `hirify hidden list` shows what is hidden.
|
|
83
95
|
|
|
84
96
|
The server defines search filters. Read `hirify filter guide`, preview with `filters.preview`, refine,
|
|
85
97
|
and then run the final search. Do not infer filter names from an old example.
|
package/bin/lib/api.js
CHANGED
|
@@ -3,9 +3,9 @@ import { CliError } from './errors.js'
|
|
|
3
3
|
import { trustedURL } from './config.js'
|
|
4
4
|
import { requireJSON, retryDelay } from './http.js'
|
|
5
5
|
import { accessRestrictedMessage, actionRequiredMessage } from './messages.js'
|
|
6
|
-
const READS = new Set(['account.status', 'vacancies.search', 'feeds.list', 'feeds.vacancies', 'profiles.list', 'webhooks.list', 'filters.guide'])
|
|
6
|
+
const READS = new Set(['account.status', 'vacancies.search', 'feeds.list', 'feeds.vacancies', 'profiles.list', 'webhooks.list', 'filters.guide', 'hidden.list'])
|
|
7
7
|
const LISTS = new Set(['vacancies.search', 'feeds.list', 'feeds.vacancies', 'profiles.list', 'webhooks.list'])
|
|
8
|
-
const OBJECTS = new Set(['account.status', 'vacancies.read', 'vacancies.reveal', 'applications.apply', 'feeds.create', 'feeds.set_delivery', 'webhooks.create', 'feedback.send', 'filters.guide'])
|
|
8
|
+
const OBJECTS = new Set(['account.status', 'vacancies.read', 'vacancies.reveal', 'applications.apply', 'feeds.create', 'feeds.set_delivery', 'webhooks.create', 'feedback.send', 'filters.guide', 'hidden.list'])
|
|
9
9
|
const isObject = value => value && typeof value === 'object' && !Array.isArray(value)
|
|
10
10
|
const immutable = value => { if (value && typeof value === 'object') { for (const child of Object.values(value)) immutable(child); Object.freeze(value) }; return value }
|
|
11
11
|
export function validateManifest(doc, origin) {
|
package/bin/lib/auth.js
CHANGED
|
@@ -73,7 +73,7 @@ export function createAuth({ config, store, http, signal, output, event, secrets
|
|
|
73
73
|
if (current?.kind === 'oauth') { await access(); return { signed_in: true, already_signed_in: true, source: config.envKey ? 'environment' : 'oauth' } }
|
|
74
74
|
}
|
|
75
75
|
const info = environment()
|
|
76
|
-
if (!noBrowser && (!info.interactive
|
|
76
|
+
if (!noBrowser && (info.remote || info.unattended || (!info.interactive && !info.gui))) throw new CliError('interaction_required', 'This terminal cannot complete browser sign-in automatically. Use HIRIFY_KEY or hirify auth --stdin. For manual sign-in, use hirify login --no-browser; over SSH, also choose --callback-port and forward that port.')
|
|
77
77
|
if (info.remote && noBrowser && !port) throw new CliError('remote_callback_required', 'Browser callbacks must reach this machine. Choose --callback-port and forward that port over SSH, or use HIRIFY_KEY.')
|
|
78
78
|
const generation = await store.beginLogin({ force })
|
|
79
79
|
const endpoints = await discover()
|
package/bin/lib/browser.js
CHANGED
|
@@ -3,7 +3,12 @@ export function environment({ env = process.env, platform = process.platform, is
|
|
|
3
3
|
const remote = Boolean(env.SSH_CONNECTION || env.SSH_TTY)
|
|
4
4
|
const wsl = platform === 'linux' && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
|
|
5
5
|
const gui = platform !== 'linux' || Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || wsl)
|
|
6
|
-
|
|
6
|
+
// A build server without a terminal has nobody to confirm access, whatever its platform
|
|
7
|
+
// says about a display. A person at a terminal is unaffected.
|
|
8
|
+
const unattended = Boolean(env.CI) && !isTTY
|
|
9
|
+
// An agent runs commands without a terminal of its own, on a desktop where a browser can open
|
|
10
|
+
// and the person can confirm. So a missing terminal alone does not rule sign-in out.
|
|
11
|
+
return { remote, wsl, interactive: isTTY, gui, unattended, canOpen: !remote && !unattended && gui }
|
|
7
12
|
}
|
|
8
13
|
export const canOpenBrowser = options => environment(options).canOpen
|
|
9
14
|
/** A launcher acknowledgement is not proof that a browser window appeared. */
|
package/bin/lib/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { CliError, redact, safeText } from './errors.js'
|
|
2
2
|
const ARGUMENTS = { auth: '[<key>]', update: '[<version>]', 'vacancy search': '[<query>]', 'vacancy read': '<slug>', 'vacancy reveal': '<slug>', 'vacancy apply': '<slug>', 'vacancy hide': '<slug>...', 'company hide': '[<name>...]', 'feed show': '<id>', 'feed create': '<name>', 'feed deliver': '<id>', 'webhook create': '<name> <url>', 'feedback send': '<bug|feature> <title>', 'api call': '<capability>', 'capabilities show': '<capability>' }
|
|
3
3
|
const GLOBAL = ['json', 'fields', 'error-format', 'debug', 'timeout', 'help']
|
|
4
|
-
export const BOOLEAN_FLAGS = new Set(['json', 'debug', 'help', 'force', 'no-browser', 'stdin', 'telegram', 'no-telegram', 'no-webhook', 'check', 'rollback', 'undo'])
|
|
4
|
+
export const BOOLEAN_FLAGS = new Set(['json', 'debug', 'help', 'force', 'no-browser', 'no-install', 'no-login', 'stdin', 'telegram', 'no-telegram', 'no-webhook', 'check', 'rollback', 'undo'])
|
|
5
5
|
export const COMMANDS = {
|
|
6
6
|
intro: { args: [0, 0], description: 'what this can do, and in what order' },
|
|
7
7
|
version: { args: [0, 0], description: 'installed version' },
|
|
8
|
+
init: { args: [0, 0], flags: ['no-install', 'no-login', 'no-browser', 'callback-port'], description: 'install the CLI and the skill, then sign in' },
|
|
8
9
|
skill: { args: [0, 0], description: 'install the working rules for your agent' },
|
|
9
10
|
login: { args: [0, 0], flags: ['force', 'no-browser', 'callback-port'], description: 'sign in through your browser' },
|
|
10
11
|
logout: { args: [0, 0], description: 'sign out on this computer' },
|
|
@@ -19,6 +20,7 @@ export const COMMANDS = {
|
|
|
19
20
|
'vacancy apply': { args: [1, 1], flags: ['profile', 'cover', 'cover-file'], description: 'send a real application; ask the person first' },
|
|
20
21
|
'vacancy hide': { args: [1, 100], flags: ['undo'], description: 'hide vacancies from your search and feeds; free, no limit' },
|
|
21
22
|
'company hide': { args: [0, 100], flags: ['vacancy', 'undo'], description: 'hide a company by name, or by --vacancy <slug>; free, no limit' },
|
|
23
|
+
'hidden list': { args: [0, 0], flags: ['page', 'limit'], description: 'what you hid: vacancies and companies' },
|
|
22
24
|
'feed list': { args: [0, 0], description: 'your saved feeds' },
|
|
23
25
|
'feed show': { args: [1, 1], flags: ['limit', 'page'], description: 'vacancies in a feed' },
|
|
24
26
|
'feed create': { args: [1, 1], flags: ['filters', 'telegram', 'no-telegram', 'webhook'], description: 'save a search and its delivery settings' },
|
|
@@ -97,11 +99,11 @@ export function parse(argv) {
|
|
|
97
99
|
if (name === 'auth' && words.length && (!words[0].trim() || /[\r\n]/.test(words[0]))) throw new CliError('invalid_arguments', 'The key must be nonempty and contain no line breaks.')
|
|
98
100
|
if (name === 'feedback send' && (!['bug', 'feature'].includes(words[0]) || !value('body'))) throw new CliError('invalid_arguments', 'Use hirify feedback send bug|feature <title> --body <text>.')
|
|
99
101
|
const args = [...parsed.options].flatMap(([key, values]) => values.map(v => v === true ? `--${key}` : `--${key}=${v}`))
|
|
100
|
-
return { name, words, args, values: Object.fromEntries([...parsed.options].map(([k, v]) => [k, v[0]])), json: Boolean(value('json')), errorJSON: value('error-format') === 'json', timeout: Number(value('timeout') || (name === 'login' ? 360 : name === 'update' ? 300 : 30)) * 1000 }
|
|
102
|
+
return { name, words, args, values: Object.fromEntries([...parsed.options].map(([k, v]) => [k, v[0]])), json: Boolean(value('json')), errorJSON: value('error-format') === 'json', timeout: Number(value('timeout') || (name === 'init' ? 600 : name === 'login' ? 360 : name === 'update' ? 300 : 30)) * 1000 }
|
|
101
103
|
}
|
|
102
104
|
export function help(topic = '') {
|
|
103
105
|
const entries = Object.entries(COMMANDS).filter(([name]) => !topic || name === topic || name.startsWith(topic + ' '))
|
|
104
|
-
return `hirify - job search for AI agents\n\n${(entries.length ? entries : Object.entries(COMMANDS)).map(([name, spec]) => ` hirify ${(name + (ARGUMENTS[name] ? ' ' + ARGUMENTS[name] : '')).padEnd(35)} ${spec.description}${spec.flags?.length ? `\n ${spec.flags.map(f => '--' + f + (BOOLEAN_FLAGS.has(f) ? '' : ' <value>')).join(' ')}` : ''}`).join('\n')}\n\nGlobal options: --json, --fields a,b, --error-format json|text, --timeout <seconds>, --debug, --help.\nUse -- to end options; --name=value and --name value are supported.\nLists and search are free. Vacancy read, reveal and apply have separate allowances.\nCheck them with hirify account show. Apply sends a real application: ask the person first.\nLocal agents reuse saved sign-in when allowed. Token renewal needs write access.\nSandbox access denied: request permission for the command; do not copy tokens.\nHeadless/CI: HIRIFY_KEY or hirify auth --stdin. Manual login: --no-browser.\nSSH: forward the chosen --callback-port to the same loopback port on the CLI host.\
|
|
106
|
+
return `hirify - job search for AI agents\n\n${(entries.length ? entries : Object.entries(COMMANDS)).map(([name, spec]) => ` hirify ${(name + (ARGUMENTS[name] ? ' ' + ARGUMENTS[name] : '')).padEnd(35)} ${spec.description}${spec.flags?.length ? `\n ${spec.flags.map(f => '--' + f + (BOOLEAN_FLAGS.has(f) ? '' : ' <value>')).join(' ')}` : ''}`).join('\n')}\n\nGlobal options: --json, --fields a,b, --error-format json|text, --timeout <seconds>, --debug, --help.\nUse -- to end options; --name=value and --name value are supported.\nLists and search are free. Vacancy read, reveal and apply have separate allowances.\nCheck them with hirify account show. Apply sends a real application: ask the person first.\nLocal agents reuse saved sign-in when allowed. Token renewal needs write access.\nSandbox access denied: request permission for the command; do not copy tokens.\nHeadless/CI: HIRIFY_KEY or hirify auth --stdin. Manual login: --no-browser.\nSSH: forward the chosen --callback-port to the same loopback port on the CLI host.\nSet up the CLI, the skill and sign-in: hirify init. The skill alone: hirify skill\n`
|
|
105
107
|
}
|
|
106
108
|
export function createOutput({ stdout = process.stdout, stderr = process.stderr, json = false, errorJSON = false, secrets = new Set() } = {}) {
|
|
107
109
|
let closed = false
|
package/bin/lib/commands.js
CHANGED
|
@@ -238,6 +238,11 @@ async function cmdVacancyRead(args, words) {
|
|
|
238
238
|
['page', d.url],
|
|
239
239
|
])
|
|
240
240
|
if (rows.length) console.log('\n' + rows.join('\n'))
|
|
241
|
+
if (d.hidden) {
|
|
242
|
+
console.log(d.hidden_reason === 'company'
|
|
243
|
+
? '\nHidden: its company is hidden, so it stays out of search and feeds.'
|
|
244
|
+
: '\nHidden: you hid this vacancy, so it stays out of search and feeds.')
|
|
245
|
+
}
|
|
241
246
|
|
|
242
247
|
const text = d.description ? asText(d.description) : ''
|
|
243
248
|
console.log(text ? `\n${text}\n` : '\nThis vacancy has no text on it.\n')
|
|
@@ -295,20 +300,60 @@ async function cmdVacancyReveal(args, words) {
|
|
|
295
300
|
* list the website's "Hide" button writes, so it works in both directions. Free and not
|
|
296
301
|
* limited; --undo brings them back. Up to 100 per call: the server takes them in one request.
|
|
297
302
|
*/
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
303
|
+
// What each result status means, in the words a person reads. The server names the outcome of
|
|
304
|
+
// every item, so nobody has to search again to find out whether a hide took.
|
|
305
|
+
const HIDE_STATUS = {
|
|
306
|
+
hidden: 'hidden', already_hidden: 'already hidden', unhidden: 'unhidden',
|
|
307
|
+
not_hidden: 'was not hidden', not_found: 'not found', no_company: 'no company name',
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function printHidden(d, many, undo) {
|
|
311
|
+
const results = Array.isArray(d.results) ? d.results : []
|
|
312
|
+
const label = (r) => r.company ? `${r.company} (${r.slug})` : (r.name ?? r.slug ?? '-')
|
|
313
|
+
const width = Math.max(0, ...results.map((r) => (HIDE_STATUS[r.status] ?? String(r.status)).length))
|
|
314
|
+
for (const r of results) {
|
|
315
|
+
const status = HIDE_STATUS[r.status] ?? String(r.status)
|
|
316
|
+
console.log(`${status.padEnd(width)} ${label(r)}${r.still_hidden_by_company ? ' (still hidden: its company is hidden)' : ''}`)
|
|
317
|
+
}
|
|
318
|
+
const summary = Object.entries(d.summary ?? {}).map(([k, n]) => `${n} ${HIDE_STATUS[k] ?? k}`).join(', ')
|
|
319
|
+
if (summary) console.log(`\n${summary}.`)
|
|
320
|
+
if (!undo && (d.summary?.hidden || d.summary?.already_hidden)) console.log(`Hidden ${many} do not appear in your search and feeds. See them: hirify hidden list`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Everything the person hid, here or on the website: vacancies page by page, all companies. */
|
|
324
|
+
async function cmdHiddenList(args) {
|
|
325
|
+
const query = new URLSearchParams()
|
|
326
|
+
const page = flag(args, '--page')
|
|
327
|
+
const limit = flag(args, '--limit')
|
|
328
|
+
if (page) query.set('page', page)
|
|
329
|
+
if (limit) query.set('per_page', limit)
|
|
330
|
+
const body = await callCapability('hidden.list', { query })
|
|
331
|
+
const d = body?.data ?? {}
|
|
332
|
+
const m = body?.meta ?? {}
|
|
333
|
+
out(body, () => {
|
|
334
|
+
const vacancies = d.vacancies ?? []
|
|
335
|
+
const companies = d.companies ?? []
|
|
336
|
+
console.log(`Hidden vacancies: ${d.totals?.vacancies ?? vacancies.length}`)
|
|
337
|
+
for (const v of vacancies) {
|
|
338
|
+
console.log(` ${v.slug}\n ${[v.title, v.company].filter(Boolean).join(' · ')}${v.archived ? ' [archived]' : ''} hidden ${dateOnly(v.hidden_at) ?? '-'}`)
|
|
339
|
+
}
|
|
340
|
+
if (m.last_page > 1) console.log(` page ${m.page} of ${m.last_page}: --page <n>`)
|
|
341
|
+
console.log(`\nHidden companies: ${d.totals?.companies ?? companies.length}`)
|
|
342
|
+
for (const c of companies) {
|
|
343
|
+
const n = count(c.live_vacancies) ?? 0
|
|
344
|
+
console.log(` ${c.name} (${n} live ${n === 1 ? 'vacancy' : 'vacancies'}) hidden ${dateOnly(c.hidden_at) ?? '-'}`)
|
|
345
|
+
}
|
|
346
|
+
if (vacancies.length || companies.length) {
|
|
347
|
+
console.log('\nUnhide: hirify vacancy hide <slug> --undo | hirify company hide "<name>" --undo')
|
|
348
|
+
}
|
|
349
|
+
})
|
|
305
350
|
}
|
|
306
351
|
|
|
307
352
|
async function cmdVacancyHide(args, words) {
|
|
308
353
|
if (!words.length) die('at least one vacancy slug is required: hirify vacancy hide <slug>...')
|
|
309
354
|
const undo = args.includes('--undo')
|
|
310
355
|
const body = await callCapability('vacancies.hide', { payload: { slugs: words, hidden: !undo } })
|
|
311
|
-
out(body, () => printHidden(body?.data ?? {}, '
|
|
356
|
+
out(body, () => printHidden(body?.data ?? {}, 'vacancies', undo))
|
|
312
357
|
}
|
|
313
358
|
|
|
314
359
|
async function cmdCompanyHide(args, words) {
|
|
@@ -317,7 +362,7 @@ async function cmdCompanyHide(args, words) {
|
|
|
317
362
|
const undo = args.includes('--undo')
|
|
318
363
|
const payload = { hidden: !undo, ...(words.length ? { names: words } : {}), ...(vacancy ? { slugs: [vacancy] } : {}) }
|
|
319
364
|
const body = await callCapability('companies.hide', { payload })
|
|
320
|
-
out(body, () => printHidden(body?.data ?? {}, '
|
|
365
|
+
out(body, () => printHidden(body?.data ?? {}, 'companies', undo))
|
|
321
366
|
}
|
|
322
367
|
|
|
323
368
|
/**
|
|
@@ -566,6 +611,9 @@ const INTRO = `hirify - job search for AI agents
|
|
|
566
611
|
Hirify is a job board. This CLI is how an agent works it for someone: the same vacancies, the
|
|
567
612
|
same saved filters and the same account they have at hirify.me.
|
|
568
613
|
|
|
614
|
+
Setting up
|
|
615
|
+
npx -y hirify-cli@latest init installs this CLI and the agent's skill, then opens sign-in.
|
|
616
|
+
|
|
569
617
|
Signing in
|
|
570
618
|
hirify login prints a link and tries the browser when available. A person confirms access.
|
|
571
619
|
Manual sign-in: hirify login --no-browser. For automation, use HIRIFY_KEY or hirify auth --stdin; get a key at
|
|
@@ -646,7 +694,7 @@ If a command you need is not here
|
|
|
646
694
|
they say what a thing costs and what a refusal means, and this one cannot.
|
|
647
695
|
|
|
648
696
|
If you are an agent
|
|
649
|
-
Install the working rules once:
|
|
697
|
+
Install the working rules once: hirify skill. They cover the order
|
|
650
698
|
above, what needs the person's permission before you do it, and what each refusal means.
|
|
651
699
|
|
|
652
700
|
Use --json for structured results and --error-format=json for structured errors.
|
|
@@ -837,18 +885,12 @@ async function cmdFilterGuide() {
|
|
|
837
885
|
out(res.body, () => console.log(guide))
|
|
838
886
|
}
|
|
839
887
|
|
|
840
|
-
/** The skill ships through skills.sh now: one command installs it into every harness. */
|
|
841
|
-
function cmdSkill() {
|
|
842
|
-
console.log('The rules for your agent install with one command:\n\n npx skills add hirifyme/hirify-cli\n')
|
|
843
|
-
console.log('It puts them where your agent reads them: Claude Code, Codex, Cursor, OpenCode and others.')
|
|
844
|
-
}
|
|
845
|
-
|
|
846
888
|
|
|
847
889
|
return {
|
|
848
|
-
intro: cmdIntro,
|
|
890
|
+
intro: cmdIntro,
|
|
849
891
|
'account show': cmdAccountShow,
|
|
850
892
|
'vacancy search': cmdVacancySearch, 'vacancy read': cmdVacancyRead, 'vacancy reveal': cmdVacancyReveal, 'vacancy apply': cmdVacancyApply,
|
|
851
|
-
'vacancy hide': cmdVacancyHide, 'company hide': cmdCompanyHide,
|
|
893
|
+
'vacancy hide': cmdVacancyHide, 'company hide': cmdCompanyHide, 'hidden list': cmdHiddenList,
|
|
852
894
|
'feed list': cmdFeedList, 'feed show': cmdFeedShow, 'feed create': cmdFeedCreate, 'feed deliver': cmdFeedDeliver,
|
|
853
895
|
'profile list': cmdProfileList, 'webhook list': cmdWebhookList, 'webhook create': cmdWebhookCreate,
|
|
854
896
|
'feedback send': cmdFeedbackSend, 'filter guide': cmdFilterGuide, 'api call': cmdApiCall,
|
package/bin/lib/main.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function main(argv = process.argv.slice(2), { baseRoot = ownRoot, b
|
|
|
55
55
|
const auth = createAuth({ config, store, http, signal, output, event, secrets })
|
|
56
56
|
const api = createApi({ config, http, auth, output })
|
|
57
57
|
const updater = createUpdater({ config, http, signal, output, event, packageRoot: ownRoot, baseRoot, baseVersion })
|
|
58
|
-
const local = ['auth', 'auth status', 'logout', 'doctor', 'skill', 'update'].includes(command.name)
|
|
58
|
+
const local = ['auth', 'auth status', 'logout', 'doctor', 'init', 'skill', 'update'].includes(command.name)
|
|
59
59
|
if (!skipUpdate && !local) next = await updater.automatic()
|
|
60
60
|
if (next) {
|
|
61
61
|
// Dispose every timer and signal handler before handing off, at most once per invocation.
|
|
@@ -63,6 +63,40 @@ export async function main(argv = process.argv.slice(2), { baseRoot = ownRoot, b
|
|
|
63
63
|
const result = await auth.login({ force: Boolean(command.values.force), noBrowser: Boolean(command.values['no-browser']), port: Number(command.values['callback-port'] || 0), consentMs: Math.min(command.timeout, 300000) })
|
|
64
64
|
if (command.json) output.json(result)
|
|
65
65
|
else { output.console.log(result.already_signed_in ? 'Already signed in. Use hirify login --force to sign in again.' : 'Signed in. Access saved on this computer.'); if (result.environment_override || result.source === 'environment') output.progress('HIRIFY_KEY is set and takes precedence over the saved sign-in.'); output.console.log('Your plan and allowances: hirify account show') }
|
|
66
|
+
} else if (command.name === 'skill') {
|
|
67
|
+
const { installSkill, describeSkill } = await import('./setup.js')
|
|
68
|
+
const result = installSkill({ packageRoot: ownRoot, env: config.env })
|
|
69
|
+
if (command.json) output.json(result); else for (const line of describeSkill(result)) output.console.log(line)
|
|
70
|
+
if (result.agents.some(item => !item.installed)) exitCode = 1
|
|
71
|
+
} else if (command.name === 'init') {
|
|
72
|
+
// The steps that need nobody go first; sign-in waits for the person and comes last.
|
|
73
|
+
const { installGlobally, installSkill, describeSkill } = await import('./setup.js')
|
|
74
|
+
const result = {}
|
|
75
|
+
let skillRoot = ownRoot
|
|
76
|
+
if (!command.values['no-install']) {
|
|
77
|
+
output.progress('Installing the Hirify CLI...')
|
|
78
|
+
// A newer CLI found on this computer stays, and the skill is taken from it.
|
|
79
|
+
const { root, ...cli } = await installGlobally({ version: pkg.version, signal, env: config.env })
|
|
80
|
+
result.cli = cli
|
|
81
|
+
if (root) skillRoot = root
|
|
82
|
+
}
|
|
83
|
+
try { result.skill = installSkill({ packageRoot: skillRoot, env: config.env }) }
|
|
84
|
+
catch (error) { if (!(error instanceof CliError)) throw error; result.skill = { error: error.code, message: error.message, agents: [] } }
|
|
85
|
+
if (!command.values['no-login']) {
|
|
86
|
+
let source = 'none'
|
|
87
|
+
try { source = auth.status().source } catch {}
|
|
88
|
+
if (['environment', 'key'].includes(source)) result.sign_in = { signed_in: true, already_signed_in: true, source }
|
|
89
|
+
else try { result.sign_in = await auth.login({ noBrowser: Boolean(command.values['no-browser']), port: Number(command.values['callback-port'] || 0), consentMs: Math.min(command.timeout, 300000) }) }
|
|
90
|
+
catch (error) { if (!(error instanceof CliError) || signal.aborted) throw error; result.sign_in = { signed_in: false, error: error.code, message: error.message } }
|
|
91
|
+
}
|
|
92
|
+
if (command.json) output.json(result)
|
|
93
|
+
else {
|
|
94
|
+
if (result.cli) output.console.log(result.cli.installed ? `Hirify CLI ${result.cli.version} is installed. The command is: hirify` : `The CLI could not be installed globally (${result.cli.reason}). Every command still works as: npx -y hirify-cli <command>`)
|
|
95
|
+
if (result.skill.error) output.console.log(result.skill.message); else for (const line of describeSkill(result.skill)) output.console.log(line)
|
|
96
|
+
if (result.sign_in) output.console.log(result.sign_in.signed_in ? (result.sign_in.already_signed_in ? 'Already signed in.' : 'Signed in. Access saved on this computer.') : result.sign_in.message)
|
|
97
|
+
if (result.sign_in?.signed_in) output.console.log('Next: ask your agent to look at your Hirify profile and saved searches.')
|
|
98
|
+
}
|
|
99
|
+
if (result.cli?.installed === false || result.skill.error || result.skill.agents.some(item => !item.installed) || result.sign_in?.signed_in === false) exitCode = 1
|
|
66
100
|
} else if (command.name === 'auth') {
|
|
67
101
|
secrets.add(command.words[0])
|
|
68
102
|
await store.commit({ kind: 'key', access_token: command.words[0] })
|
|
@@ -90,8 +124,7 @@ export async function main(argv = process.argv.slice(2), { baseRoot = ownRoot, b
|
|
|
90
124
|
else { const result = await updater.install(target, { pin: Boolean(command.words[0] || config.env.HIRIFY_VERSION_PIN) }); if (command.json) output.json(result); else output.console.log(`Hirify CLI ${result.version} is ready for the next command.${result.pinned ? ' This version is pinned.' : ''}`) }
|
|
91
125
|
} else {
|
|
92
126
|
const commands = createCommands({ api, output, config, signal, event, fields: command.values.fields })
|
|
93
|
-
|
|
94
|
-
else await commands[command.name](command.args, command.words)
|
|
127
|
+
await commands[command.name](command.args, command.words)
|
|
95
128
|
}
|
|
96
129
|
} catch (error) { exitCode = output.error(error) }
|
|
97
130
|
finally { await cleanup(); await output.flush().catch(() => { exitCode = 1 }); output.dispose() }
|
package/bin/lib/setup.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, cpSync, rmSync, symlinkSync, lstatSync, realpathSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import semver from 'semver'
|
|
5
|
+
import { CliError } from './errors.js'
|
|
6
|
+
import { PACKAGE_NAME, runProcess } from './update.js'
|
|
7
|
+
|
|
8
|
+
export const SKILL_NAME = 'hirify'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where each agent reads its global skills, and the directory whose presence means the agent is
|
|
12
|
+
* installed on this computer. The paths follow the registry of the `skills` tool, so the skill
|
|
13
|
+
* lands in the same place whether it comes from here or from `npx skills add`.
|
|
14
|
+
*/
|
|
15
|
+
export function agentDirectories({ env = process.env, home = homedir() } = {}) {
|
|
16
|
+
const config = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
|
|
17
|
+
const claude = env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude')
|
|
18
|
+
const codex = env.CODEX_HOME?.trim() || join(home, '.codex')
|
|
19
|
+
return [
|
|
20
|
+
{ name: 'Claude Code', home: claude, skills: join(claude, 'skills') },
|
|
21
|
+
{ name: 'Codex', home: codex, skills: join(codex, 'skills') },
|
|
22
|
+
{ name: 'Cursor', home: join(home, '.cursor'), skills: join(home, '.cursor', 'skills') },
|
|
23
|
+
{ name: 'OpenCode', home: join(config, 'opencode'), skills: join(config, 'opencode', 'skills') },
|
|
24
|
+
{ name: 'Gemini CLI', home: join(home, '.gemini'), skills: join(home, '.gemini', 'skills') },
|
|
25
|
+
{ name: 'Antigravity', home: join(home, '.gemini', 'antigravity'), skills: join(home, '.gemini', 'antigravity', 'skills') },
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const entry = path => { try { return lstatSync(path) } catch (error) { if (error.code === 'ENOENT') return null; throw error } }
|
|
30
|
+
const real = path => { try { return realpathSync(path) } catch { return path } }
|
|
31
|
+
// Removing a link removes the link, never the directory it points to.
|
|
32
|
+
const remove = path => { if (entry(path)) rmSync(path, { recursive: true, force: true }) }
|
|
33
|
+
const readJSON = file => { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null } }
|
|
34
|
+
// OS messages may carry private paths, so only the code is reported.
|
|
35
|
+
const reason = error => ['EACCES', 'EPERM', 'EROFS', 'ENOSPC', 'EDQUOT', 'ENOTDIR', 'ENOENT', 'EEXIST', 'ELOOP'].includes(error?.code) ? error.code : 'unknown'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Install the skill carried by this package: one shared copy under `~/.agents/skills`, linked into
|
|
39
|
+
* every agent found here. The skill comes from the package of the CLI it is for, so the two match.
|
|
40
|
+
* An agent that cannot be written to is reported and does not stop the others.
|
|
41
|
+
*/
|
|
42
|
+
export function installSkill({ packageRoot, env = process.env, home = homedir(), platform = process.platform } = {}) {
|
|
43
|
+
const source = join(packageRoot, 'skills', SKILL_NAME)
|
|
44
|
+
if (!existsSync(join(source, 'SKILL.md'))) throw new CliError('skill_missing', 'This installation does not carry the skill. Install it with: npx skills add hirifyme/hirify-cli')
|
|
45
|
+
const shared = join(home, '.agents', 'skills')
|
|
46
|
+
const path = join(shared, SKILL_NAME)
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(shared, { recursive: true })
|
|
49
|
+
remove(path)
|
|
50
|
+
cpSync(source, path, { recursive: true })
|
|
51
|
+
} catch (error) { throw new CliError('skill_install_failed', `Could not save the skill (${reason(error)}). This command needs write access to the home directory.`) }
|
|
52
|
+
const agents = []
|
|
53
|
+
for (const agent of agentDirectories({ env, home })) {
|
|
54
|
+
if (!existsSync(agent.home)) continue
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(agent.skills, { recursive: true })
|
|
57
|
+
// An agent whose skills directory already is the shared one needs no link of its own.
|
|
58
|
+
const parent = real(agent.skills)
|
|
59
|
+
if (parent === real(shared)) { agents.push({ agent: agent.name, installed: true }); continue }
|
|
60
|
+
const target = join(parent, SKILL_NAME)
|
|
61
|
+
remove(target)
|
|
62
|
+
try {
|
|
63
|
+
// A junction needs no elevated rights on Windows; elsewhere the link stays relative.
|
|
64
|
+
if (platform === 'win32') symlinkSync(real(path), target, 'junction')
|
|
65
|
+
else symlinkSync(relative(parent, real(path)), target, 'dir')
|
|
66
|
+
} catch { cpSync(source, target, { recursive: true }) }
|
|
67
|
+
agents.push({ agent: agent.name, installed: true })
|
|
68
|
+
} catch (error) { agents.push({ agent: agent.name, installed: false, reason: reason(error) }) }
|
|
69
|
+
}
|
|
70
|
+
return { skill: SKILL_NAME, path, agents }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Put the CLI on PATH with the person's own npm and its settings: the environment goes to npm as
|
|
75
|
+
* it is, because what npx adds to it is the same configuration, already resolved. A failure is a
|
|
76
|
+
* result, not an error: without a global installation every command still runs as
|
|
77
|
+
* `npx -y hirify-cli <command>`. A newer global installation is kept, never downgraded, and its
|
|
78
|
+
* `root` says where the skill that matches it is.
|
|
79
|
+
*/
|
|
80
|
+
export async function installGlobally({ version, signal, env = process.env, run = runProcess } = {}) {
|
|
81
|
+
const options = { signal, env }
|
|
82
|
+
let root
|
|
83
|
+
try { root = await run('npm', ['root', '--global'], { ...options, timeout: 10000 }) }
|
|
84
|
+
catch (error) { if (signal?.aborted) throw error; return { installed: false, reason: 'npm_unavailable' } }
|
|
85
|
+
if (root.code !== 0 || !root.stdout.trim()) return { installed: false, reason: 'npm_unavailable' }
|
|
86
|
+
const location = join(root.stdout.trim(), PACKAGE_NAME)
|
|
87
|
+
const current = readJSON(join(location, 'package.json'))
|
|
88
|
+
if (current?.name === PACKAGE_NAME && semver.valid(current.version) && semver.gte(current.version, version)) return { installed: true, version: current.version, changed: false, ...(semver.gt(current.version, version) && { root: location }) }
|
|
89
|
+
let result
|
|
90
|
+
try { result = await run('npm', ['install', '--global', '--ignore-scripts', '--no-audit', '--no-fund', `${PACKAGE_NAME}@${version}`], { ...options, timeout: 180000 }) }
|
|
91
|
+
catch (error) { if (signal?.aborted) throw error; return { installed: false, reason: 'install_failed' } }
|
|
92
|
+
return result.code === 0 ? { installed: true, version, changed: true } : { installed: false, reason: 'install_failed' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Lines a person reads after `hirify skill` or `hirify init`. */
|
|
96
|
+
export function describeSkill(result) {
|
|
97
|
+
const done = result.agents.filter(item => item.installed).map(item => item.agent)
|
|
98
|
+
const failed = result.agents.filter(item => !item.installed).map(item => `${item.agent} (${item.reason})`)
|
|
99
|
+
const lines = [done.length ? `The skill is installed for ${done.join(', ')}.` : `The skill is saved in ${result.path}. No supported agent was found on this computer.`]
|
|
100
|
+
if (failed.length) lines.push(`It could not be installed for ${failed.join(', ')}. Check that the agent's skills directory is a folder you can write to, then run hirify skill.`)
|
|
101
|
+
return lines
|
|
102
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hirify-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "hirify-cli",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.6.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"cross-spawn": "7.0.6",
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hirify-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Hirify CLI for AI agents: browser sign-in, job feeds, search, reading a vacancy in full, contacts to apply to, and applying on Hirify.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hirify": "bin/hirify.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "node --test scripts/test.mjs scripts/runtime.test.mjs scripts/network.test.mjs scripts/update.test.mjs scripts/storage.test.mjs",
|
|
9
|
+
"test": "node --test scripts/test.mjs scripts/runtime.test.mjs scripts/network.test.mjs scripts/update.test.mjs scripts/storage.test.mjs scripts/setup.test.mjs",
|
|
10
10
|
"build": "node scripts/build.mjs",
|
|
11
11
|
"test:package": "node scripts/verify-package.mjs",
|
|
12
12
|
"prepublishOnly": "node scripts/prepublish-check.mjs",
|
package/skills/hirify/SKILL.md
CHANGED
|
@@ -10,16 +10,17 @@ has every command in full, and `hirify intro` is the server's guide.
|
|
|
10
10
|
|
|
11
11
|
## Setup and automation
|
|
12
12
|
|
|
13
|
-
Package: `hirify-cli`; command: `hirify`; Node >=18.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
SSH needs `--callback-port` with a matching SSH local port forward. Local
|
|
21
|
-
saved sign-in when permitted to access the same configuration directory.
|
|
22
|
-
can use `HIRIFY_KEY` or `hirify auth --stdin`; never put a key in a
|
|
13
|
+
Package: `hirify-cli`; command: `hirify`; Node >=18.
|
|
14
|
+
If `hirify` is missing, run `npx -y hirify-cli@latest init`: it installs the CLI and this skill,
|
|
15
|
+
then signs in. Until then use `npx -y hirify-cli <command>`. Global installs update themselves;
|
|
16
|
+
pin an exact version with `HIRIFY_NO_AUTO_UPDATE=1`.
|
|
17
|
+
|
|
18
|
+
To sign in, run `hirify login`; the person confirms access in the browser within five minutes.
|
|
19
|
+
It fails at once over SSH, and without a terminal in CI or with no display. `--no-browser` is
|
|
20
|
+
manual callback login. SSH needs `--callback-port` with a matching SSH local port forward. Local
|
|
21
|
+
agents reuse the person's saved sign-in when permitted to access the same configuration directory.
|
|
22
|
+
CI and separate containers can use `HIRIFY_KEY` or `hirify auth --stdin`; never put a key in a
|
|
23
|
+
command, log or chat.
|
|
23
24
|
`hirify auth status --json` identifies the local source. Environment keys override saved access;
|
|
24
25
|
logout is local and does not unset them. Use `hirify login --force` to replace expired access.
|
|
25
26
|
|
|
@@ -49,7 +50,7 @@ no permission. Sending, saving and configuring do.
|
|
|
49
50
|
3. `vacancy read <slug>` to assess fit from the full text rather than a headline.
|
|
50
51
|
4. `vacancy reveal <slug>` only for vacancies that still fit after reading.
|
|
51
52
|
5. Apply only after the person's approval and the rules below.
|
|
52
|
-
6. `vacancy hide
|
|
53
|
+
6. `vacancy hide`/`company hide` what the user rejected; `hidden list` shows it.
|
|
53
54
|
|
|
54
55
|
## Searching
|
|
55
56
|
|
|
@@ -62,10 +62,14 @@ outlives every change to them.
|
|
|
62
62
|
|
|
63
63
|
## Signing in
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
SSH requires `--callback-port` and a
|
|
68
|
-
|
|
65
|
+
`hirify login` prints a link, opens the browser where it can and waits until the person confirms
|
|
66
|
+
access. An agent on a local desktop may run it and ask the person to confirm. `--no-browser`
|
|
67
|
+
disables launching but still needs a reachable callback; SSH requires `--callback-port` and a
|
|
68
|
+
matching port forward. CI should use `HIRIFY_KEY` or `hirify auth --stdin`. Over SSH, and without
|
|
69
|
+
a terminal in CI or with no display, a new browser login fails promptly unless manual mode was
|
|
70
|
+
explicit.
|
|
71
|
+
`npx -y hirify-cli@latest init` installs the CLI and this skill and then signs in; `hirify skill`
|
|
72
|
+
installs the skill alone.
|
|
69
73
|
Access is stored under an absolute XDG_CONFIG_HOME or `~/.config/hirify`, with private file permissions
|
|
70
74
|
and atomic writes. `auth status --json` shows its source. `logout` clears local access, not server
|
|
71
75
|
access or environment keys. `HIRIFY_KEY` wins over the saved sign-in. Use `login --force` to replace
|