job-forge 2.14.21 → 2.14.23

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.
@@ -0,0 +1,105 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ cacheKey,
5
+ hasCacheEntry,
6
+ listCacheEntries,
7
+ pruneCache,
8
+ putCacheEntry,
9
+ readCacheContent,
10
+ resolveCacheDir,
11
+ verifyCache,
12
+ } from '@razroo/iso-cache';
13
+
14
+ export const CACHE_DIR = '.jobforge-cache';
15
+ export const JD_CACHE_NAMESPACE = 'jobforge.jd';
16
+ export const JD_CACHE_VERSION = '1';
17
+ export const JD_CACHE_KIND = 'jd';
18
+ export const DEFAULT_JD_TTL_MS = 14 * 24 * 60 * 60 * 1000;
19
+
20
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
21
+ return projectDir;
22
+ }
23
+
24
+ export function jobForgeCacheDir(projectDir = resolveProjectDir()) {
25
+ return process.env.JOB_FORGE_CACHE || join(projectDir, CACHE_DIR);
26
+ }
27
+
28
+ export function jobForgeCacheSummary(projectDir = resolveProjectDir()) {
29
+ const root = resolveCacheDir(jobForgeCacheDir(projectDir));
30
+ const entries = listCacheEntries(root, { includeExpired: true });
31
+ return {
32
+ root,
33
+ exists: existsSync(root),
34
+ entries: entries.length,
35
+ active: entries.filter((entry) => !entry.expiresAt || new Date(entry.expiresAt).getTime() > Date.now()).length,
36
+ };
37
+ }
38
+
39
+ export function jobDescriptionCacheKey(url) {
40
+ return cacheKey({
41
+ namespace: JD_CACHE_NAMESPACE,
42
+ version: JD_CACHE_VERSION,
43
+ parts: { url: normalizeJobUrl(url) },
44
+ });
45
+ }
46
+
47
+ export function putJobDescriptionCache(url, content, options = {}, projectDir = resolveProjectDir()) {
48
+ const normalizedUrl = normalizeJobUrl(url);
49
+ return putCacheEntry(
50
+ jobForgeCacheDir(projectDir),
51
+ jobDescriptionCacheKey(normalizedUrl),
52
+ content,
53
+ {
54
+ kind: options.kind || JD_CACHE_KIND,
55
+ contentType: options.contentType || 'text/markdown',
56
+ ttlMs: options.expiresAt ? undefined : (options.ttlMs ?? DEFAULT_JD_TTL_MS),
57
+ expiresAt: options.expiresAt,
58
+ metadata: {
59
+ url: normalizedUrl,
60
+ source: options.source || 'job-forge',
61
+ ...(options.metadata || {}),
62
+ },
63
+ },
64
+ );
65
+ }
66
+
67
+ export function readJobDescriptionCache(url, options = {}, projectDir = resolveProjectDir()) {
68
+ return readCacheContent(jobForgeCacheDir(projectDir), jobDescriptionCacheKey(url), options);
69
+ }
70
+
71
+ export function hasJobDescriptionCache(url, options = {}, projectDir = resolveProjectDir()) {
72
+ return hasCacheEntry(jobForgeCacheDir(projectDir), jobDescriptionCacheKey(url), options);
73
+ }
74
+
75
+ export function readJobForgeCache(key, options = {}, projectDir = resolveProjectDir()) {
76
+ return readCacheContent(jobForgeCacheDir(projectDir), key, options);
77
+ }
78
+
79
+ export function putJobForgeCache(key, content, options = {}, projectDir = resolveProjectDir()) {
80
+ return putCacheEntry(jobForgeCacheDir(projectDir), key, content, options);
81
+ }
82
+
83
+ export function listJobForgeCache(options = {}, projectDir = resolveProjectDir()) {
84
+ return listCacheEntries(jobForgeCacheDir(projectDir), options);
85
+ }
86
+
87
+ export function verifyJobForgeCache(projectDir = resolveProjectDir()) {
88
+ return verifyCache(jobForgeCacheDir(projectDir));
89
+ }
90
+
91
+ export function pruneJobForgeCache(options = {}, projectDir = resolveProjectDir()) {
92
+ return pruneCache(jobForgeCacheDir(projectDir), options);
93
+ }
94
+
95
+ export function normalizeJobUrl(url) {
96
+ const text = String(url || '').trim();
97
+ if (!text) throw new Error('url is required');
98
+ try {
99
+ const parsed = new URL(text);
100
+ parsed.hash = '';
101
+ return parsed.toString();
102
+ } catch {
103
+ return text;
104
+ }
105
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import {
4
+ buildIndex,
5
+ hasIndexRecord,
6
+ loadIndexConfig,
7
+ parseJson,
8
+ queryIndex,
9
+ verifyIndex,
10
+ } from '@razroo/iso-index';
11
+
12
+ export const INDEX_FILE = '.jobforge-index.json';
13
+ export const INDEX_CONFIG_FILE = 'templates/index.json';
14
+
15
+ export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
16
+ return projectDir;
17
+ }
18
+
19
+ export function jobForgeIndexPath(projectDir = resolveProjectDir()) {
20
+ return process.env.JOB_FORGE_INDEX || join(projectDir, INDEX_FILE);
21
+ }
22
+
23
+ export function jobForgeIndexConfigPath(projectDir = resolveProjectDir()) {
24
+ return process.env.JOB_FORGE_INDEX_CONFIG || join(projectDir, INDEX_CONFIG_FILE);
25
+ }
26
+
27
+ export function indexExists(projectDir = resolveProjectDir()) {
28
+ return existsSync(jobForgeIndexPath(projectDir));
29
+ }
30
+
31
+ export function readJobForgeIndexConfig(projectDir = resolveProjectDir()) {
32
+ const path = jobForgeIndexConfigPath(projectDir);
33
+ return loadIndexConfig(parseJson(readFileSync(path, 'utf8'), path));
34
+ }
35
+
36
+ export function buildJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
37
+ const config = readJobForgeIndexConfig(projectDir);
38
+ const index = buildIndex(config, { root: projectDir });
39
+ const out = options.out || jobForgeIndexPath(projectDir);
40
+ if (options.write !== false) {
41
+ writeFileSync(out, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
42
+ }
43
+ return { index, out };
44
+ }
45
+
46
+ export function readJobForgeIndex(projectDir = resolveProjectDir()) {
47
+ const path = jobForgeIndexPath(projectDir);
48
+ return parseJson(readFileSync(path, 'utf8'), path);
49
+ }
50
+
51
+ export function ensureJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
52
+ if (options.rebuild !== false || !indexExists(projectDir)) {
53
+ return buildJobForgeIndex({ out: options.out }, projectDir).index;
54
+ }
55
+ return readJobForgeIndex(projectDir);
56
+ }
57
+
58
+ export function queryJobForgeIndex(query = {}, options = {}, projectDir = resolveProjectDir()) {
59
+ return queryIndex(ensureJobForgeIndex(options, projectDir), query);
60
+ }
61
+
62
+ export function hasJobForgeIndexRecord(query = {}, options = {}, projectDir = resolveProjectDir()) {
63
+ return hasIndexRecord(ensureJobForgeIndex(options, projectDir), query);
64
+ }
65
+
66
+ export function verifyJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
67
+ const index = options.index || ensureJobForgeIndex(options, projectDir);
68
+ return verifyIndex(index);
69
+ }
70
+
71
+ export function jobForgeIndexSummary(projectDir = resolveProjectDir()) {
72
+ if (!indexExists(projectDir)) {
73
+ return {
74
+ path: jobForgeIndexPath(projectDir),
75
+ config: jobForgeIndexConfigPath(projectDir),
76
+ exists: false,
77
+ records: 0,
78
+ files: 0,
79
+ sources: 0,
80
+ };
81
+ }
82
+ const index = readJobForgeIndex(projectDir);
83
+ return {
84
+ path: jobForgeIndexPath(projectDir),
85
+ config: jobForgeIndexConfigPath(projectDir),
86
+ exists: true,
87
+ records: index.stats?.records || 0,
88
+ files: index.stats?.files || 0,
89
+ sources: index.stats?.sources || 0,
90
+ configHash: index.configHash,
91
+ };
92
+ }
@@ -21,7 +21,9 @@ Fetch the JD content once. If the input is a **URL** (not pasted JD text), fetch
21
21
 
22
22
  **If the input is JD text** (not a URL): use it directly, no fetching needed.
23
23
 
24
- **Local artifacts before Step 0 methods:** Grep `reports/` for the URL or stable company+role slug; if a report already embeds the full JD, Read it and skip network fetch entirely. If the pipeline row or `jds/` references `local:jds/{file}`, Read that file first. This stacks with the rule above: one fetch per URL per session, and **zero** if the JD is already on disk.
24
+ **Local artifacts before Step 0 methods:** Grep `reports/` for the URL or stable company+role slug; if a report already embeds the full JD, Read it and skip network fetch entirely. If the pipeline row or `jds/` references `local:jds/{file}`, Read that file first. For URL inputs, run `npx job-forge cache:has --url "{url}"` and then `npx job-forge cache:get --url "{url}"` on a hit; cached JD text is authoritative local content and avoids a browser/network fetch. This stacks with the rule above: one fetch per URL per session, and **zero** if the JD is already on disk or in `.jobforge-cache/`.
25
+
26
+ **Cache after a successful fetch:** If the JD text is written to `jds/` or embedded in a report, store that exact text with `npx job-forge cache:put --url "{url}" --ttl 14d --input @path/to/jd.md`. Do not refetch just to populate the cache.
25
27
 
26
28
  ## Step 1 — Run Evaluation A-F
27
29
  Execute exactly as in the `offer` mode (read `modes/offer.md` for all blocks A-F).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.21",
3
+ "version": "2.14.23",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,6 +41,20 @@
41
41
  "context:plan": "node bin/job-forge.mjs context:plan",
42
42
  "context:check": "node bin/job-forge.mjs context:check",
43
43
  "context:render": "node bin/job-forge.mjs context:render",
44
+ "cache:key": "node bin/job-forge.mjs cache:key",
45
+ "cache:has": "node bin/job-forge.mjs cache:has",
46
+ "cache:get": "node bin/job-forge.mjs cache:get",
47
+ "cache:put": "node bin/job-forge.mjs cache:put",
48
+ "cache:status": "node bin/job-forge.mjs cache:status",
49
+ "cache:list": "node bin/job-forge.mjs cache:list",
50
+ "cache:verify": "node bin/job-forge.mjs cache:verify",
51
+ "cache:prune": "node bin/job-forge.mjs cache:prune",
52
+ "index:build": "node bin/job-forge.mjs index:build",
53
+ "index:status": "node bin/job-forge.mjs index:status",
54
+ "index:query": "node bin/job-forge.mjs index:query",
55
+ "index:has": "node bin/job-forge.mjs index:has",
56
+ "index:verify": "node bin/job-forge.mjs index:verify",
57
+ "index:explain": "node bin/job-forge.mjs index:explain",
44
58
  "plan": "iso plan .",
45
59
  "lint:agentmd": "agentmd lint iso/instructions.md",
46
60
  "lint:modes": "isolint lint modes/",
@@ -58,7 +72,9 @@
58
72
  "bin/",
59
73
  "iso/",
60
74
  "models.yaml",
61
- ".claude/",
75
+ ".claude/settings.json",
76
+ ".claude/iso-route.resolved.json",
77
+ ".claude/agents/",
62
78
  ".cursor/mcp.json",
63
79
  ".cursor/rules/",
64
80
  ".cursor/iso-route.md",
@@ -106,9 +122,11 @@
106
122
  },
107
123
  "dependencies": {
108
124
  "@razroo/iso-capabilities": "^0.1.0",
125
+ "@razroo/iso-cache": "^0.1.0",
109
126
  "@razroo/iso-context": "^0.1.0",
110
127
  "@razroo/iso-contract": "^0.1.0",
111
128
  "@razroo/iso-guard": "^0.1.0",
129
+ "@razroo/iso-index": "^0.1.0",
112
130
  "@razroo/iso-ledger": "^0.1.0",
113
131
  "@razroo/iso-orchestrator": "^0.1.0",
114
132
  "@razroo/iso-trace": "^0.4.0",
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync } from 'fs';
4
+ import {
5
+ formatCacheEntries,
6
+ formatPruneResult,
7
+ formatVerifyResult,
8
+ } from '@razroo/iso-cache';
9
+ import { PROJECT_DIR } from '../tracker-lib.mjs';
10
+ import {
11
+ DEFAULT_JD_TTL_MS,
12
+ JD_CACHE_KIND,
13
+ jobDescriptionCacheKey,
14
+ jobForgeCacheDir,
15
+ jobForgeCacheSummary,
16
+ listJobForgeCache,
17
+ pruneJobForgeCache,
18
+ putJobDescriptionCache,
19
+ putJobForgeCache,
20
+ readJobDescriptionCache,
21
+ readJobForgeCache,
22
+ verifyJobForgeCache,
23
+ } from '../lib/jobforge-cache.mjs';
24
+
25
+ const USAGE = `job-forge cache - local deterministic artifact cache
26
+
27
+ Usage:
28
+ job-forge cache:key --url <url> [--json]
29
+ job-forge cache:status [--json]
30
+ job-forge cache:has (--url <url> | --key <key>) [--allow-expired] [--json]
31
+ job-forge cache:get (--url <url> | --key <key>) [--allow-expired] [--output <file>] [--json]
32
+ job-forge cache:put (--url <url> | --key <key>) --input <text|@file|-> [--ttl 14d] [--kind <kind>] [--content-type <type>] [--meta <json|@file>] [--json]
33
+ job-forge cache:list [--kind <kind>] [--include-expired] [--json]
34
+ job-forge cache:verify [--json]
35
+ job-forge cache:prune [--expired] [--dry-run] [--json]
36
+ job-forge cache:path
37
+
38
+ Default path is .jobforge-cache/ unless JOB_FORGE_CACHE is set. This is local
39
+ project state, not an MCP and not prompt context.`;
40
+
41
+ const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
42
+ const opts = parseArgs(rawArgs);
43
+
44
+ if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
45
+ console.log(USAGE);
46
+ process.exit(0);
47
+ }
48
+
49
+ try {
50
+ if (cmd === 'path') {
51
+ console.log(jobForgeCacheDir(PROJECT_DIR));
52
+ } else if (cmd === 'key') {
53
+ key(opts);
54
+ } else if (cmd === 'status') {
55
+ status(opts);
56
+ } else if (cmd === 'has') {
57
+ has(opts);
58
+ } else if (cmd === 'get') {
59
+ get(opts);
60
+ } else if (cmd === 'put') {
61
+ put(opts);
62
+ } else if (cmd === 'list') {
63
+ list(opts);
64
+ } else if (cmd === 'verify') {
65
+ verify(opts);
66
+ } else if (cmd === 'prune') {
67
+ prune(opts);
68
+ } else {
69
+ console.error(`unknown cache command "${cmd}"\n`);
70
+ console.error(USAGE);
71
+ process.exit(2);
72
+ }
73
+ } catch (error) {
74
+ console.error(error instanceof Error ? error.message : String(error));
75
+ process.exit(1);
76
+ }
77
+
78
+ function parseArgs(args) {
79
+ const opts = {
80
+ json: false,
81
+ help: false,
82
+ allowExpired: false,
83
+ includeExpired: false,
84
+ dryRun: false,
85
+ expired: false,
86
+ ttlMs: DEFAULT_JD_TTL_MS,
87
+ metadata: {},
88
+ };
89
+
90
+ for (let i = 0; i < args.length; i++) {
91
+ const arg = args[i];
92
+ if (arg === '--json') {
93
+ opts.json = true;
94
+ } else if (arg === '--url') {
95
+ opts.url = valueAfter(args, ++i, '--url');
96
+ } else if (arg.startsWith('--url=')) {
97
+ opts.url = arg.slice('--url='.length);
98
+ } else if (arg === '--key') {
99
+ opts.key = valueAfter(args, ++i, '--key');
100
+ } else if (arg.startsWith('--key=')) {
101
+ opts.key = arg.slice('--key='.length);
102
+ } else if (arg === '--input') {
103
+ opts.input = valueAfter(args, ++i, '--input');
104
+ } else if (arg.startsWith('--input=')) {
105
+ opts.input = arg.slice('--input='.length);
106
+ } else if (arg === '--output') {
107
+ opts.output = valueAfter(args, ++i, '--output');
108
+ } else if (arg.startsWith('--output=')) {
109
+ opts.output = arg.slice('--output='.length);
110
+ } else if (arg === '--kind') {
111
+ opts.kind = valueAfter(args, ++i, '--kind');
112
+ } else if (arg.startsWith('--kind=')) {
113
+ opts.kind = arg.slice('--kind='.length);
114
+ } else if (arg === '--content-type') {
115
+ opts.contentType = valueAfter(args, ++i, '--content-type');
116
+ } else if (arg.startsWith('--content-type=')) {
117
+ opts.contentType = arg.slice('--content-type='.length);
118
+ } else if (arg === '--ttl') {
119
+ opts.ttlMs = parseDuration(valueAfter(args, ++i, '--ttl'));
120
+ } else if (arg.startsWith('--ttl=')) {
121
+ opts.ttlMs = parseDuration(arg.slice('--ttl='.length));
122
+ } else if (arg === '--expires-at') {
123
+ opts.expiresAt = valueAfter(args, ++i, '--expires-at');
124
+ opts.ttlMs = undefined;
125
+ } else if (arg.startsWith('--expires-at=')) {
126
+ opts.expiresAt = arg.slice('--expires-at='.length);
127
+ opts.ttlMs = undefined;
128
+ } else if (arg === '--meta') {
129
+ opts.metadata = parseMetadata(valueAfter(args, ++i, '--meta'));
130
+ } else if (arg.startsWith('--meta=')) {
131
+ opts.metadata = parseMetadata(arg.slice('--meta='.length));
132
+ } else if (arg === '--allow-expired') {
133
+ opts.allowExpired = true;
134
+ } else if (arg === '--include-expired') {
135
+ opts.includeExpired = true;
136
+ } else if (arg === '--dry-run') {
137
+ opts.dryRun = true;
138
+ } else if (arg === '--expired') {
139
+ opts.expired = true;
140
+ } else if (arg === '--help' || arg === '-h') {
141
+ opts.help = true;
142
+ } else {
143
+ throw new Error(`unknown flag "${arg}"`);
144
+ }
145
+ }
146
+
147
+ return opts;
148
+ }
149
+
150
+ function valueAfter(values, index, flag) {
151
+ const value = values[index];
152
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
153
+ return value;
154
+ }
155
+
156
+ function key(opts) {
157
+ const keyValue = keyFromOptions(opts);
158
+ if (opts.json) {
159
+ console.log(JSON.stringify({ key: keyValue, url: opts.url || null }, null, 2));
160
+ return;
161
+ }
162
+ console.log(keyValue);
163
+ }
164
+
165
+ function status(opts) {
166
+ const summary = jobForgeCacheSummary(PROJECT_DIR);
167
+ if (opts.json) {
168
+ console.log(JSON.stringify(summary, null, 2));
169
+ return;
170
+ }
171
+ console.log(`cache: ${summary.root}`);
172
+ console.log(`exists: ${summary.exists ? 'yes' : 'no'}`);
173
+ console.log(`entries: ${summary.active} active, ${summary.entries - summary.active} expired`);
174
+ }
175
+
176
+ function has(opts) {
177
+ const hit = read(opts);
178
+ if (opts.json) {
179
+ console.log(JSON.stringify({ hit: Boolean(hit?.hit), stale: Boolean(hit?.stale), key: keyFromOptions(opts) }, null, 2));
180
+ } else {
181
+ console.log(hit?.hit ? `HIT${hit.stale ? ' stale' : ''}` : 'MISS');
182
+ }
183
+ process.exit(hit?.hit ? 0 : 1);
184
+ }
185
+
186
+ function get(opts) {
187
+ const hit = read(opts);
188
+ if (!hit?.hit || hit.content === undefined) {
189
+ if (opts.json) {
190
+ console.log(JSON.stringify({ hit: false, stale: Boolean(hit?.stale), key: keyFromOptions(opts) }, null, 2));
191
+ } else {
192
+ console.log('MISS');
193
+ }
194
+ process.exit(1);
195
+ }
196
+ if (opts.output) {
197
+ writeFileSync(opts.output, hit.content, 'utf8');
198
+ }
199
+ if (opts.json) {
200
+ console.log(JSON.stringify({ hit: true, stale: hit.stale, entry: hit.entry, content: opts.output ? undefined : hit.content }, null, 2));
201
+ } else if (opts.output) {
202
+ console.log(`WROTE ${opts.output}`);
203
+ } else {
204
+ process.stdout.write(hit.content);
205
+ if (!hit.content.endsWith('\n')) process.stdout.write('\n');
206
+ }
207
+ }
208
+
209
+ function put(opts) {
210
+ if (!opts.input) throw new Error('cache:put requires --input <text|@file|->');
211
+ const content = readInput(opts.input);
212
+ const entry = opts.url
213
+ ? putJobDescriptionCache(opts.url, content, {
214
+ kind: opts.kind || JD_CACHE_KIND,
215
+ contentType: opts.contentType,
216
+ ttlMs: opts.ttlMs,
217
+ expiresAt: opts.expiresAt,
218
+ metadata: opts.metadata,
219
+ }, PROJECT_DIR)
220
+ : putJobForgeCache(requiredKey(opts), content, {
221
+ kind: opts.kind,
222
+ contentType: opts.contentType,
223
+ ttlMs: opts.expiresAt ? undefined : opts.ttlMs,
224
+ expiresAt: opts.expiresAt,
225
+ metadata: opts.metadata,
226
+ }, PROJECT_DIR);
227
+ if (opts.json) {
228
+ console.log(JSON.stringify(entry, null, 2));
229
+ return;
230
+ }
231
+ console.log(`STORED ${entry.key} ${entry.contentHash}`);
232
+ }
233
+
234
+ function list(opts) {
235
+ const entries = listJobForgeCache({
236
+ kind: opts.kind,
237
+ includeExpired: opts.includeExpired,
238
+ }, PROJECT_DIR);
239
+ if (opts.json) {
240
+ console.log(JSON.stringify(entries, null, 2));
241
+ return;
242
+ }
243
+ console.log(formatCacheEntries(entries));
244
+ }
245
+
246
+ function verify(opts) {
247
+ const result = verifyJobForgeCache(PROJECT_DIR);
248
+ if (opts.json) {
249
+ console.log(JSON.stringify(result, null, 2));
250
+ } else {
251
+ console.log(formatVerifyResult(result));
252
+ }
253
+ process.exit(result.ok ? 0 : 1);
254
+ }
255
+
256
+ function prune(opts) {
257
+ const result = pruneJobForgeCache({
258
+ expired: opts.expired || undefined,
259
+ dryRun: opts.dryRun,
260
+ }, PROJECT_DIR);
261
+ if (opts.json) {
262
+ console.log(JSON.stringify(result, null, 2));
263
+ return;
264
+ }
265
+ console.log(formatPruneResult(result));
266
+ }
267
+
268
+ function read(opts) {
269
+ if (opts.url) {
270
+ return readJobDescriptionCache(opts.url, { allowExpired: opts.allowExpired }, PROJECT_DIR);
271
+ }
272
+ return readJobForgeCache(requiredKey(opts), { allowExpired: opts.allowExpired }, PROJECT_DIR);
273
+ }
274
+
275
+ function keyFromOptions(opts) {
276
+ if (opts.url) return jobDescriptionCacheKey(opts.url);
277
+ return requiredKey(opts);
278
+ }
279
+
280
+ function requiredKey(opts) {
281
+ if (!opts.key) throw new Error('expected --url or --key');
282
+ return opts.key;
283
+ }
284
+
285
+ function readInput(input) {
286
+ if (input === '-') return readFileSync(0, 'utf8');
287
+ if (input.startsWith('@')) return readFileSync(input.slice(1), 'utf8');
288
+ return input;
289
+ }
290
+
291
+ function parseMetadata(raw) {
292
+ const text = raw.startsWith('@') ? readFileSync(raw.slice(1), 'utf8') : raw;
293
+ const parsed = JSON.parse(text);
294
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
295
+ throw new Error('--meta must be a JSON object');
296
+ }
297
+ return parsed;
298
+ }
299
+
300
+ function parseDuration(raw) {
301
+ const match = /^(\d+)(ms|s|m|h|d)?$/.exec(String(raw).trim());
302
+ if (!match) throw new Error('--ttl must be a duration like 14d, 2h, 30m, 10s, or 500ms');
303
+ const value = Number(match[1]);
304
+ const unit = match[2] || 'ms';
305
+ const multipliers = {
306
+ ms: 1,
307
+ s: 1000,
308
+ m: 60 * 1000,
309
+ h: 60 * 60 * 1000,
310
+ d: 24 * 60 * 60 * 1000,
311
+ };
312
+ return value * multipliers[unit];
313
+ }