hirify-cli 0.5.4 → 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 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 --help
14
- npx skills add hirifyme/hirify-cli
23
+ hirify skill
24
+ hirify login
15
25
  ```
16
26
 
17
- For a one-off sign-in, run `npx hirify-cli login`.
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; agents should ask
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
- | Non-interactive terminal, CI, agent pipe | Fails promptly if a new browser sign-in is needed; use a key or explicit manual login |
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 |
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 || info.remote)) 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.')
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()
@@ -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
- return { remote, wsl, interactive: isTTY, gui, canOpen: isTTY && !remote && gui }
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' },
@@ -98,11 +99,11 @@ export function parse(argv) {
98
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.')
99
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>.')
100
101
  const args = [...parsed.options].flatMap(([key, values]) => values.map(v => v === true ? `--${key}` : `--${key}=${v}`))
101
- 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 }
102
103
  }
103
104
  export function help(topic = '') {
104
105
  const entries = Object.entries(COMMANDS).filter(([name]) => !topic || name === topic || name.startsWith(topic + ' '))
105
- 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.\nRules for your agent: npx skills add hirifyme/hirify-cli\n`
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`
106
107
  }
107
108
  export function createOutput({ stdout = process.stdout, stderr = process.stderr, json = false, errorJSON = false, secrets = new Set() } = {}) {
108
109
  let closed = false
@@ -611,6 +611,9 @@ const INTRO = `hirify - job search for AI agents
611
611
  Hirify is a job board. This CLI is how an agent works it for someone: the same vacancies, the
612
612
  same saved filters and the same account they have at hirify.me.
613
613
 
614
+ Setting up
615
+ npx -y hirify-cli@latest init installs this CLI and the agent's skill, then opens sign-in.
616
+
614
617
  Signing in
615
618
  hirify login prints a link and tries the browser when available. A person confirms access.
616
619
  Manual sign-in: hirify login --no-browser. For automation, use HIRIFY_KEY or hirify auth --stdin; get a key at
@@ -691,7 +694,7 @@ If a command you need is not here
691
694
  they say what a thing costs and what a refusal means, and this one cannot.
692
695
 
693
696
  If you are an agent
694
- Install the working rules once: npx skills add hirifyme/hirify-cli. They cover the order
697
+ Install the working rules once: hirify skill. They cover the order
695
698
  above, what needs the person's permission before you do it, and what each refusal means.
696
699
 
697
700
  Use --json for structured results and --error-format=json for structured errors.
@@ -882,15 +885,9 @@ async function cmdFilterGuide() {
882
885
  out(res.body, () => console.log(guide))
883
886
  }
884
887
 
885
- /** The skill ships through skills.sh now: one command installs it into every harness. */
886
- function cmdSkill() {
887
- console.log('The rules for your agent install with one command:\n\n npx skills add hirifyme/hirify-cli\n')
888
- console.log('It puts them where your agent reads them: Claude Code, Codex, Cursor, OpenCode and others.')
889
- }
890
-
891
888
 
892
889
  return {
893
- intro: cmdIntro, skill: cmdSkill,
890
+ intro: cmdIntro,
894
891
  'account show': cmdAccountShow,
895
892
  'vacancy search': cmdVacancySearch, 'vacancy read': cmdVacancyRead, 'vacancy reveal': cmdVacancyReveal, 'vacancy apply': cmdVacancyApply,
896
893
  'vacancy hide': cmdVacancyHide, 'company hide': cmdCompanyHide, 'hidden list': cmdHiddenList,
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
- if (command.name === 'skill' && command.json) output.json({ install: 'npx skills add hirifyme/hirify-cli' })
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() }
@@ -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
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "hirify-cli",
3
- "version": "0.5.4",
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.5.4",
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.5.4",
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",
@@ -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. Prefer Node 22 or 24.
14
- Without a global install use `npx -y hirify-cli <command>` (npm may cache it).
15
- Offer `npm install -g hirify-cli` only with permission. Global installs can update automatically;
16
- for reproducible work use an exact version and `HIRIFY_NO_AUTO_UPDATE=1`.
17
-
18
- A person runs `hirify login` and confirms the printed link. The CLI tries a browser only when
19
- appropriate. Do not run login for them. `--no-browser` is manual callback login, not device flow.
20
- SSH needs `--callback-port` with a matching SSH local port forward. Local agents reuse the person's
21
- saved sign-in when permitted to access the same configuration directory. CI and separate containers
22
- can use `HIRIFY_KEY` or `hirify auth --stdin`; never put a key in a command, log or chat.
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
 
@@ -62,10 +62,14 @@ outlives every change to them.
62
62
 
63
63
  ## Signing in
64
64
 
65
- A person runs `hirify login`, opens the printed link and confirms access. The CLI tries a browser
66
- only where appropriate. `--no-browser` disables launching but still needs a reachable callback;
67
- SSH requires `--callback-port` and a matching port forward. CI/agents should use `HIRIFY_KEY` or
68
- `hirify auth --stdin`. A new non-interactive browser login fails promptly unless manual mode was explicit.
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