jobless 1.5.0 → 1.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
@@ -1,94 +1,148 @@
1
1
  # jobless
2
2
 
3
- [![jobless](https://img.shields.io/badge/npx-jobless-b91c1c)](https://www.npmjs.com/package/jobless)
4
- [![npm](https://img.shields.io/npm/dw/jobless?label=npm%20downloads)](https://www.npmjs.com/package/jobless)
3
+ [![npm](https://img.shields.io/npm/v/jobless)](https://www.npmjs.com/package/jobless)
4
+ [![npm downloads](https://img.shields.io/npm/dw/jobless)](https://www.npmjs.com/package/jobless)
5
+
6
+ I got invited to interview and rejected at the same time.
7
+ So I turned the hiring process into an npm package.
8
+
9
+ They asked how I handle rejection. Apparently: semantic versioning.
5
10
 
6
11
  ```bash
7
12
  npx jobless
8
13
  ```
9
14
 
10
- First it rejects **you**. Then it introduces an engineer.
15
+ ```text
16
+ ✔ Congratulations! We'd love to interview you.
17
+ ✔ Unfortunately, we're moving forward with others.
18
+
19
+ ERR_HIRING_RACE_CONDITION
20
+ Application status: yesn't
11
21
 
12
- `jobless` is a reverse ATS. The market has been parsing humans in 400ms. This package parses you, then parses the job post, then shows [Yonas Alem](https://yonasalem.vercel.app) — senior software engineer, remote, production systems.
22
+ Interview scheduled successfully.
23
+ Candidate rejected successfully.
24
+ All systems operational.
13
25
 
14
- ## The trap
26
+ I couldn't fix their hiring pipeline. So I shipped my own.
27
+ ```
15
28
 
16
- You run the command. It pretends to be Greenhouse, Lever, Ashby, or Workday. It reads a couple of harmless local hints (git name, email, laptop user). It writes a rejection letter addressed to you, with a fake ATS score and a pile of applicants.
29
+ A hiring simulator, a reverse ATS, and an actual engineer behind the joke.
30
+ The simulator is satire. The contradictory interview is the origin story.
17
31
 
18
- Then it breaks character and offers `l` — paste the posting that just rejected you — or `h` to hire.
32
+ ## Try it. Break it. Send it to a friend.
19
33
 
20
- Skip the theater with `npx jobless --resume`. Record it with `--seed=42` and `--no-color`.
34
+ In the interactive prompt:
21
35
 
22
- ## Lint the posting, not the human
36
+ - `r` — try again. Same pipeline, different disappointment.
37
+ - `d` — reject a fictional job posting. No file needed.
38
+ - `l` — paste a real posting; finish with an empty line.
39
+ - `c` — copy the latest result, including the opening joke.
40
+ - `h` — meet the engineer.
41
+ - `q` — leave the hiring pipeline. Finally, a working exit.
42
+
43
+ ```bash
44
+ npx jobless share # a compact, anonymous result
45
+ npx jobless share --copy # explicitly copy it
46
+ npx jobless tweet # the origin story, ready to post
47
+ npx jobless --seed=1 # another hiring incident
48
+ npx jobless --record # anonymous, no animation or prompt
49
+ npx jobless --record --seed=42 --no-color
50
+ ```
51
+
52
+ There are three hand-written scenarios. Seeds select a repeatable scenario;
53
+ `r` cycles through them. No AI API, account, or subscription needed.
54
+
55
+ ## Reject the posting for a change
56
+
57
+ ```bash
58
+ npx jobless lint --demo
59
+ ```
60
+
61
+ Meet DemoCorp: competitive salary, six interview rounds, an unpaid take-home,
62
+ and a remote role that requires office attendance. Let it hear back from you.
23
63
 
24
64
  ```bash
25
65
  npx jobless lint posting.txt
26
66
  npx jobless lint jobs/
27
67
  pbpaste | npx jobless lint
28
- npx jobless lint --fix posting.txt
29
- npx jobless lint --format=github jobs/
30
68
  npx jobless lint --tweet posting.txt
69
+ npx jobless lint --copy posting.txt
70
+ npx jobless lint --json posting.txt
31
71
  npx jobless lint --rules
32
72
  ```
33
73
 
34
- Or run `npx jobless` and press `l`. `c` copies the last rejection letter.
74
+ The score is rule-based satire, **not an objective employer rating**. It flags
75
+ phrases; it cannot judge the full context of a role or hiring process.
35
76
 
36
- ```
37
- error too many interview rounds
38
- max-rounds
39
- L3 Six interview rounds.
77
+ Errors exit with status `1`, including the intentionally bad demo. Warnings
78
+ alone exit with `0`, unless you set `--max-warnings=0`.
40
79
 
41
- RESULT REJECTED
42
- 28/100
43
- FIT overlap Senior · Remote
80
+ ```bash
81
+ npx jobless lint --fix posting.txt # proposed unified diff only
82
+ npx jobless lint --write posting.txt # explicitly overwrite the file
44
83
  ```
45
84
 
46
- Errors fail the process (`exit 1`). `--fix` prints a unified diff and does not write files unless you pass `--write`. `--format=github` emits `::error` annotations. `jobless-disable-next-line no-fake-salary` skips a line.
47
-
48
- ### GitHub Action
85
+ Review suggested rewrites: they include placeholder terms, not facts about the role.
86
+ `--demo` cannot be combined with file paths or `--write`.
87
+ Use `--off=rule-id` or `jobless-disable-next-line rule-id` to skip a rule.
49
88
 
50
- Copy [`examples/github-action.yml`](examples/github-action.yml):
89
+ For job postings in your own repository, see
90
+ [the example GitHub Action](examples/github-action.yml):
51
91
 
52
92
  ```yaml
53
93
  - run: npx --yes jobless lint --format=github jobs/
54
94
  ```
55
95
 
56
- ### GitHub profile README
96
+ ## There is an engineer behind this
57
97
 
58
- ```markdown
59
- [![npx jobless](https://img.shields.io/badge/npx-jobless-b91c1c)](https://www.npmjs.com/package/jobless)
60
- ```
98
+ I'm [Yonas Alem](https://yonasalem.vercel.app), a senior software engineer in
99
+ Addis Ababa, open to remote roles. I debug silent production failures.
100
+ This one was loud enough to become a package.
61
101
 
62
102
  ```bash
63
- npx jobless
103
+ npx jobless hire # contact details; no automatic clipboard writes
104
+ npx jobless hire --copy # explicitly copy my email
105
+ npx jobless work # three case studies and experience
106
+ npx jobless proof # the deploy that killed 17 cron jobs
107
+ npx jobless card # compact contact card
108
+ npx jobless why # the origin story
109
+ npx jobless rant # five short coping mechanisms
110
+ npx jobless --resume # go straight to the engineer
111
+ npx jobless --json # machine-readable profile
112
+ npx jobless --help # all commands
64
113
  ```
65
114
 
66
- ## Hire
115
+ Infra and performance claims belong to my work at Mereb. Payment volume
116
+ belongs to the products I integrated at Eaglelion, not a ledger I built.
117
+ Production code is private; the case studies explain the work and its limits.
118
+
119
+ - [Email](mailto:yonalem21@gmail.com)
120
+ - [Portfolio](https://yonasalem.vercel.app)
121
+ - [GitHub](https://github.com/Yonas21)
122
+ - [LinkedIn](https://www.linkedin.com/in/yonasalem21/)
67
123
 
68
- The numbers have owners. Infra and performance: Mereb. Payment volume: client rails at Eaglelion, not the ledger. `npx jobless work` is three case studies. `npx jobless proof` is the 17-cron write-up. Production code is private; the walkthrough is the interview.
124
+ ## Local, with boundaries
69
125
 
70
- - Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
71
- - Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
72
- - GitHub: [github.com/Yonas21](https://github.com/Yonas21)
73
- - LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
74
- - Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
126
+ Zero runtime dependencies. Node 18+. The CLI does not apply to jobs, send email,
127
+ scrape websites, or send telemetry. Fetching it through `npx` uses npm's network.
75
128
 
76
- `npx jobless card` is the one-screen version. `npx jobless proof` is the 17-cron write-up. `npx jobless vcf` is a vCard. `npx jobless why` is twelve lines. `npx jobless hire --to=manager` or `--to=recruiter`. `npx jobless tweet` is 280 characters with no punchline.
129
+ The default simulator and linter use an anonymous candidate identity. They do
130
+ not read your Git identity, username, or working directory for personalization.
131
+ `--personalize` opts into local identity lookup for the simulator's greeting;
132
+ it can read Git name/email and OS account details, but only shows your name.
133
+ `--record`, `share`, and copied simulator results always omit that local identity.
134
+ A pasted posting can still contain personal or company information: inspect
135
+ lint output before sharing it.
77
136
 
78
- ## Commands
137
+ Clipboard writes happen only when requested with `c`, `copy`, or `--copy`.
138
+ They use `pbcopy` on macOS, `clip` on Windows, or `xclip` on Linux. If copying is
139
+ unavailable, share commands leave the text visible for manual copying.
140
+
141
+ ## Development
79
142
 
80
143
  ```bash
81
- npx jobless # reject you, then the resume
82
- npx jobless lint file.txt # lint a job post (exit 1 on errors)
83
- npx jobless lint jobs/ # lint a directory of postings
84
- npx jobless lint --fix # unified diff (dry-run)
85
- npx jobless lint --json # findings + rejection letter
86
- npx jobless work # three case studies
87
- npx jobless hire # email + links
88
- npx jobless rant # the market
89
- npx jobless --resume # skip the rejection
144
+ npm test
145
+ node bin/jobless.js
90
146
  ```
91
147
 
92
- Post the command, not the punchline: `npx jobless`
93
-
94
- Zero dependencies. Node 18+. Local only. It does not apply to jobs or send email.
148
+ Zero dependencies. Still dependent on someone replying.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jobless",
3
- "version": "1.5.0",
4
- "description": "npx jobless — first it rejects you. then it lints the job post. then you meet the engineer.",
3
+ "version": "1.6.0",
4
+ "description": "Invited to interview and rejected at the same time. A hiring race condition, reproduced in your terminal.",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "npx",
package/src/cli.js CHANGED
@@ -1,124 +1,36 @@
1
- import { createInterface } from 'node:readline';
2
- import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { stdin } from 'node:process';
6
-
1
+ import { readFileSync } from 'node:fs';
7
2
  import { menu } from './data.js';
8
- import { unifiedDiff } from './diff.js';
9
- import { fixJobPost, githubAnnotations, lintResult } from './lint.js';
10
- import { letterText, printTrap } from './reject.js';
11
- import {
12
- blank,
13
- clear,
14
- dim,
15
- green,
16
- print,
17
- showCursor,
18
- } from './style.js';
3
+ import { runLintCli } from './lint-cli.js';
4
+ import { interactive } from './interactive.js';
5
+ import { hiringResult, printHiring, printShare, shareText } from './hiring.js';
6
+ import { blank, dim, print, showCursor, wrap } from './style.js';
19
7
  import {
20
- copyLetter,
21
- printCard,
22
- printFix,
23
- printHeader,
24
- printHelp,
25
- printHire,
26
- printLandingTeaser,
27
- printLintReport,
28
- printLooking,
29
- printMenu,
30
- printProof,
31
- printRant,
32
- printRules,
33
- printSignature,
34
- printSkills,
35
- printTweet,
36
- printVcf,
37
- printWho,
38
- printWhy,
39
- printWork,
40
- toJson,
8
+ printCard, printHeader, printHelp, printHire, printLandingTeaser,
9
+ printLooking, printMenu, printProof, printRant, printRules, printSignature,
10
+ printSkills, printTweet, printVcf, printWho, printWhy, printWork, toJson,
41
11
  } from './screens.js';
42
12
 
43
- const pkg = JSON.parse(
44
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
45
- );
46
-
47
- let lastReport = null;
48
- let lastTo = '';
49
-
50
- const COMMANDS = {
51
- who: printWho,
52
- about: printWho,
53
- looking: printLooking,
54
- jobs: printLooking,
55
- rant: printRant,
56
- market: printRant,
57
- work: printWork,
58
- proof: printProof,
59
- experience: printWork,
60
- skills: printSkills,
61
- hire: () => printHire({ report: lastReport, to: lastTo }),
62
- h: () => printHire({ report: lastReport, to: lastTo }),
63
- links: () => printHire({ report: lastReport, to: lastTo }),
64
- contact: () => printHire({ report: lastReport, to: lastTo }),
65
- card: printCard,
66
- tweet: () => printTweet(lastReport),
67
- why: printWhy,
68
- vcf: printVcf,
69
- };
70
-
13
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
71
14
  const VALUE_FLAGS = new Set(['--max-warnings', '--seed', '--off', '--format', '--to']);
72
-
73
- const ask = (rl, prompt) =>
74
- new Promise((resolve) => {
75
- rl.question(prompt, resolve);
76
- });
77
-
78
- const readStdin = async () => {
79
- const chunks = [];
80
- for await (const chunk of stdin) chunks.push(chunk);
81
- return Buffer.concat(chunks).toString('utf8');
15
+ const COMMANDS = {
16
+ who: printWho, about: printWho, looking: printLooking, jobs: printLooking,
17
+ rant: printRant, market: printRant, work: printWork, experience: printWork,
18
+ proof: printProof, skills: printSkills, card: printCard, why: printWhy,
19
+ help: printHelp,
82
20
  };
83
-
84
- const readPaste = async (rl) => {
85
- blank();
86
- print(dim(' paste a job description. empty line to lint.'));
87
- blank();
88
- const lines = [];
89
- while (true) {
90
- const line = await ask(rl, dim(' '));
91
- if (line === '') break;
92
- lines.push(line);
93
- }
94
- return lines.join('\n');
21
+ const aliases = {
22
+ q: 'quit', exit: 'quit', l: 'lint', c: 'copy', r: 'replay', d: 'demo',
23
+ h: 'hire', links: 'hire', contact: 'hire', vcard: 'vcf',
95
24
  };
96
-
97
25
  const resolveCommand = (input) => {
98
26
  const value = String(input).trim().toLowerCase();
99
- if (!value) return null;
100
- if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
101
- if (value === 'help') return 'help';
102
- if (value === 'lint' || value === 'l') return 'lint';
103
- if (value === 'c' || value === 'copy') return 'copy';
104
- if (value === 'tweet') return 'tweet';
105
- if (value === 'card') return 'card';
106
- if (value === 'proof') return 'proof';
107
- if (value === 'why') return 'why';
108
- if (value === 'vcf' || value === 'vcard') return 'vcf';
109
- const fromMenu = menu.find((item) => item.key === value || item.command === value);
110
- if (fromMenu) return fromMenu.command;
111
- if (COMMANDS[value]) return value;
27
+ if (aliases[value]) return aliases[value];
28
+ const item = menu.find((entry) => entry.key === value);
29
+ if (item) return item.command;
30
+ if (COMMANDS[value] || ['quit', 'lint', 'copy', 'replay', 'demo', 'share', 'hire', 'vcf', 'tweet'].includes(value)) return value;
112
31
  return null;
113
32
  };
114
33
 
115
- const runCommand = (command) => {
116
- const render = COMMANDS[command];
117
- if (!render) return false;
118
- render();
119
- return true;
120
- };
121
-
122
34
  const showResume = () => {
123
35
  printHeader();
124
36
  printSignature();
@@ -173,284 +85,71 @@ const applyColorFlags = (flags, opts) => {
173
85
  if (flags.has('--no-color') || opts.color === 'never') process.env.NO_COLOR = '1';
174
86
  };
175
87
 
176
- const POSTING_RE = /\.(md|txt|markdown)$/i;
177
-
178
- const walkPostings = (dir, acc = []) => {
179
- for (const name of readdirSync(dir).sort()) {
180
- if (name.startsWith('.')) continue;
181
- const path = join(dir, name);
182
- let st;
183
- try {
184
- st = statSync(path);
185
- } catch {
186
- continue;
187
- }
188
- if (st.isDirectory()) walkPostings(path, acc);
189
- else if (POSTING_RE.test(name)) acc.push(path);
190
- }
191
- return acc;
192
- };
193
-
194
- const collectPostings = (paths) => {
195
- const files = [];
196
- for (const path of paths) {
197
- const st = statSync(path);
198
- if (st.isDirectory()) walkPostings(path, files);
199
- else files.push(path);
200
- }
201
- return files;
202
- };
203
-
204
- const resolveFormat = (flags, opts) => {
205
- if (opts.format) return opts.format;
206
- if (flags.has('--json')) return 'json';
207
- return 'pretty';
208
- };
209
-
210
- const lintOne = (text, source, { off, fix, write }) => {
211
- const report = lintResult(text, source, { off });
212
- if (fix || write) {
213
- const fixed = fixJobPost(text, { off });
214
- report.fix = {
215
- applied: fixed.applied,
216
- text: fixed.text,
217
- path: source,
218
- before: text,
219
- diff: unifiedDiff(text, fixed.text, source === 'stdin' || source === 'paste' ? 'posting.txt' : source),
220
- };
221
- if (write && source !== 'stdin' && source !== 'paste' && fixed.applied.length) {
222
- writeFileSync(source, fixed.text);
223
- report.fix.wrote = true;
224
- }
225
- }
226
- return report;
227
- };
228
-
229
- const runLintCli = async (files, options) => {
230
- const { format = 'pretty', fix = false, write = false, copy = false, quiet = false, off = [], maxWarnings, tweet = false } = options;
231
-
232
- const inputs = [];
233
- if (files[0]) {
234
- const paths = collectPostings(files);
235
- if (!paths.length) {
236
- print(dim(` no .md/.txt postings under ${files.join(', ')}`));
237
- process.exitCode = 1;
238
- return;
239
- }
240
- for (const path of paths) {
241
- inputs.push({ source: path, text: readFileSync(path, 'utf8') });
242
- }
243
- } else if (!stdin.isTTY) {
244
- inputs.push({ source: 'stdin', text: await readStdin() });
245
- } else {
246
- printHelp();
247
- blank();
248
- print(dim(' lint needs a file, a directory, a pipe, or an interactive paste: npx jobless lint jobs/'));
249
- process.exitCode = 1;
250
- return;
251
- }
252
-
253
- const reports = inputs.map((input) => lintOne(input.text, input.source, { off, fix: fix || write, write }));
254
- lastReport = reports.at(-1);
255
-
256
- let failed = false;
257
- for (const report of reports) {
258
- if (!report.ok) failed = true;
259
- if (maxWarnings != null && report.warnings > maxWarnings) failed = true;
260
- }
261
- if (failed) process.exitCode = 1;
262
-
263
- if (format === 'json') {
264
- print(JSON.stringify(reports.length === 1 ? reports[0] : { reports }, null, 2));
265
- if (copy && lastReport?.letter) copyLetter(lastReport.letter);
266
- return;
267
- }
268
-
269
- if (format === 'github') {
270
- const body = reports.map((report) => githubAnnotations(report)).filter(Boolean).join('\n');
271
- if (body) print(body);
272
- return;
273
- }
274
-
275
- for (const [index, report] of reports.entries()) {
276
- if (index) blank();
277
- const copied = copy && report.letter ? copyLetter(report.letter) : false;
278
- printLintReport(report, { quiet, copied });
279
- if (report.fix?.applied.length) printFix(report.fix);
280
- if (report.fix?.wrote) print(` ${green('wrote')} ${report.source}`);
281
- }
282
-
283
- if (tweet) {
284
- blank();
285
- printTweet(lastReport);
286
- }
287
-
288
- blank();
289
- print(dim(' more: npx jobless · npx jobless hire'));
290
- };
291
-
292
- const interactive = async ({ trap, seed }) => {
293
- if (process.stdout.isTTY) clear();
294
- if (trap) {
295
- await printTrap({ animate: true, seed });
296
- blank();
297
- }
298
- showResume();
299
-
300
- const rl = createInterface({ input: process.stdin, output: process.stdout });
301
- try {
302
- while (true) {
303
- const answer = await ask(rl, dim(' jobless> '));
304
- const command = resolveCommand(answer);
305
-
306
- if (!command) {
307
- print(` ${dim('unknown. try 1–5, l to lint, c to copy, h to hire, or q.')}`);
308
- continue;
309
- }
310
- if (command === 'help') {
311
- if (process.stdout.isTTY) clear();
312
- printHelp();
313
- printMenu();
314
- continue;
315
- }
316
- if (command === 'copy') {
317
- if (!lastReport?.letter) {
318
- print(` ${dim('nothing to copy. lint a posting first (l).')}`);
319
- continue;
320
- }
321
- if (copyLetter(lastReport.letter)) print(` ${green('copied')} rejection letter`);
322
- else print(letterText(lastReport.letter));
323
- continue;
324
- }
325
- if (command === 'lint') {
326
- const text = await readPaste(rl);
327
- if (process.stdout.isTTY) clear();
328
- printHeader();
329
- lastReport = lintResult(text, 'paste');
330
- printLintReport(lastReport);
331
- printMenu();
332
- continue;
333
- }
334
- if (command === 'quit') {
335
- blank();
336
- print(dim(' still jobless. still shipping.'));
337
- print(dim(' yonalem21@gmail.com'));
338
- blank();
339
- break;
340
- }
341
-
342
- if (process.stdout.isTTY) clear();
343
- printHeader();
344
- runCommand(command);
345
- printMenu();
346
- }
347
- } finally {
348
- rl.close();
349
- showCursor();
350
- }
351
- };
352
-
353
88
  export const run = async (argv = []) => {
354
89
  process.stdout.on('error', (error) => {
355
90
  if (error.code === 'EPIPE') process.exit(0);
356
91
  });
357
-
358
92
  const { flags, opts, rest } = parse(argv);
359
93
  applyColorFlags(flags, opts);
94
+ const verb = rest[0]?.toLowerCase() || '';
95
+ const command = resolveCommand(verb);
96
+ const runCommand = (name, report = null) => {
97
+ if (name === 'hire') printHire({ report, to: opts.to, copy: flags.has('--copy') });
98
+ else if (name === 'vcf') printVcf({ copy: flags.has('--copy') });
99
+ else if (name === 'tweet') printTweet(report);
100
+ else COMMANDS[name]?.();
101
+ };
360
102
 
361
- const verb = rest[0] ? rest[0].toLowerCase() : '';
362
-
363
- if (flags.has('-h') || flags.has('--help') || verb === 'help') {
364
- printHelp();
365
- return;
366
- }
367
-
368
- if (flags.has('-v') || flags.has('--version')) {
369
- print(pkg.version);
370
- return;
371
- }
372
-
373
- if (verb === 'rules' || flags.has('--rules')) {
374
- printRules();
375
- return;
376
- }
103
+ if (flags.has('-h') || flags.has('--help') || command === 'help') return printHelp();
104
+ if (flags.has('-v') || flags.has('--version')) return print(pkg.version);
105
+ if (verb === 'rules' || flags.has('--rules')) return printRules();
377
106
 
378
- if (verb === 'lint' || verb === 'l') {
107
+ if (command === 'lint' || command === 'demo') {
379
108
  const maxRaw = opts['max-warnings'];
380
109
  const maxWarnings = maxRaw == null || maxRaw === '' ? null : Number(maxRaw);
381
110
  await runLintCli(rest.slice(1), {
382
- format: resolveFormat(flags, opts),
111
+ format: opts.format || (flags.has('--json') ? 'json' : 'pretty'),
383
112
  fix: flags.has('--fix') || flags.has('--diff') || flags.has('--write'),
384
- write: flags.has('--write'),
385
- copy: flags.has('--copy'),
386
- quiet: flags.has('--quiet'),
387
- tweet: flags.has('--tweet'),
388
- off: String(opts.off || '')
389
- .split(',')
390
- .map((id) => id.trim())
391
- .filter(Boolean),
113
+ write: flags.has('--write'), copy: flags.has('--copy'),
114
+ quiet: flags.has('--quiet'), tweet: flags.has('--tweet'),
115
+ demo: command === 'demo' || flags.has('--demo'),
116
+ off: String(opts.off || '').split(',').map((id) => id.trim()).filter(Boolean),
392
117
  maxWarnings: Number.isFinite(maxWarnings) ? maxWarnings : null,
393
118
  });
394
119
  return;
395
120
  }
121
+ if (flags.has('--json')) return print(toJson());
122
+ if (verb && !command) throw new Error(`Unknown command: ${verb}. Try npx jobless --help.`);
396
123
 
397
- if (verb === 'tweet') {
398
- printTweet(lastReport);
399
- return;
400
- }
401
-
402
- if (verb === 'why') {
403
- printHeader();
404
- printWhy();
405
- return;
406
- }
407
-
408
- if (verb === 'vcf' || verb === 'vcard') {
409
- printHeader();
410
- printVcf();
411
- return;
412
- }
413
-
414
- lastTo = opts.to || '';
415
- if ((verb === 'hire' || verb === 'h') && lastTo) {
416
- printHeader();
417
- printHire({ report: lastReport, to: lastTo, copy: process.stdout.isTTY });
418
- blank();
419
- print(dim(' more: npx jobless · npx jobless --help'));
420
- return;
421
- }
422
-
423
- if (flags.has('--json')) {
424
- print(toJson());
124
+ const record = flags.has('--record');
125
+ const seed = opts.seed ?? 0;
126
+ // Recording and exported results always omit local identity, even with --personalize.
127
+ const personalize = flags.has('--personalize') && !record;
128
+ if (command === 'share' || command === 'copy') {
129
+ printShare(shareText(hiringResult({ seed })), { copy: command === 'copy' || flags.has('--copy') });
425
130
  return;
426
131
  }
427
-
428
- const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
429
- const seed = opts.seed;
430
- const command = verb ? resolveCommand(verb) : null;
431
-
432
- if (command && command !== 'quit' && command !== 'help' && command !== 'lint' && command !== 'copy') {
433
- printHeader();
132
+ if (command === 'quit') return;
133
+ if (command && command !== 'replay') {
134
+ if (!['why', 'tweet', 'rant'].includes(command)) printHeader();
434
135
  runCommand(command);
435
- blank();
436
- print(dim(' more: npx jobless · npx jobless hire · npx jobless --help'));
437
136
  return;
438
137
  }
439
-
440
- if (!process.stdout.isTTY) {
441
- if (!skipTrap) await printTrap({ animate: false, seed });
442
- blank();
443
- printHeader();
444
- printSignature();
445
- printHire({ copy: false, report: lastReport });
138
+ const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
139
+ if (!skipTrap) hiringResult({ seed }); // Validate before starting an interactive prompt.
140
+ if (record || !process.stdout.isTTY || !process.stdin.isTTY) {
141
+ if (skipTrap) showResume();
142
+ else {
143
+ await printHiring({ seed, personalize });
144
+ blank();
145
+ for (const line of wrap('Next: npx jobless share | npx jobless lint --demo')) print(dim(` ${line}`));
146
+ }
446
147
  return;
447
148
  }
448
-
449
- process.on('SIGINT', () => {
149
+ process.once('SIGINT', () => {
450
150
  showCursor();
451
151
  process.stdout.write('\n');
452
152
  process.exit(0);
453
153
  });
454
-
455
- await interactive({ trap: !skipTrap, seed });
154
+ await interactive({ trap: !skipTrap, seed, personalize, resolveCommand, runCommand, showResume });
456
155
  };