job-apply-plugin 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "name": "job-apply",
10
10
  "source": "./plugin",
11
11
  "description": "Prepare and submit individual job applications using a résumé and profile saved on your device.",
12
- "version": "0.3.0",
12
+ "version": "0.5.0",
13
13
  "category": "productivity"
14
14
  }
15
15
  ]
package/README.md CHANGED
@@ -74,6 +74,32 @@ npm install -g job-apply-plugin && job-apply-plugin install
74
74
  The installer never overwrites a folder it didn't create unless you pass `--force`.
75
75
  When you add Job Apply to an existing `marketplace.json`, the other entries in that file are kept.
76
76
 
77
+ ## Updating
78
+
79
+ ```bash
80
+ npx job-apply-plugin@latest update
81
+ ```
82
+
83
+ This updates Job Apply to the newest version in every app where it's installed, without the
84
+ menu. `npx job-apply-plugin status` tells you when a newer version is out.
85
+
86
+ To switch to a specific version, including going back to an older one:
87
+
88
+ ```bash
89
+ npx job-apply-plugin@latest update 0.3.0
90
+ ```
91
+
92
+ List the available versions with `npm view job-apply-plugin versions`. Updating never touches
93
+ your résumé, profile or history. `--claude` or `--chatgpt` limits the update to one app.
94
+
95
+ - **ChatGPT:** after updating, open **Plugins → Job Apply** and reinstall it, then restart the
96
+ app. ChatGPT keeps its own copy and doesn't pick up new files until you do.
97
+ - **Claude Code:** start a new session to use the new version.
98
+
99
+ > Keep `@latest` in the command. Without it, npx may run a copy it downloaded earlier.
100
+ > Versions before 0.5.0 have no `update` command; for those, `npx job-apply-plugin@latest update`
101
+ > still works, because it always runs the newest installer.
102
+
77
103
  ## Uninstall
78
104
 
79
105
  ```bash
@@ -84,14 +110,24 @@ It asks which app to remove Job Apply from, and shows where it's currently insta
84
110
 
85
111
  ```
86
112
  Remove Job Apply from which app?
87
- 1) Claude Code (installed v0.3.0)
113
+ 1) Claude Code (installed v0.5.0)
88
114
  2) ChatGPT desktop / Codex (not installed)
89
115
  3) Both
90
116
  Choose 1, 2 or 3 [1]:
91
117
  ```
92
118
 
93
119
  To skip the menu, use `--claude`, `--chatgpt` or `--all`. `remove` works as another name for `uninstall`.
94
- For ChatGPT, also click **Uninstall** on its Plugins page, then restart the app.
120
+
121
+ What it removes:
122
+
123
+ | App | Removed |
124
+ | --- | --- |
125
+ | Claude Code | The two skill folders in `~/.claude/skills`, plus a project install (`./.claude/skills`) in the folder you run it from |
126
+ | ChatGPT / Codex | `~/.codex/plugins/job-apply`, its entry in `~/.agents/plugins/marketplace.json`, ChatGPT's own installed copy (`~/.codex/plugins/cache/…/job-apply`), and its settings in `~/.codex/config.toml` |
127
+
128
+ Restart the app afterwards. It only deletes things that belong to this plugin; other plugins
129
+ and skills are left alone. A project install in a different folder has to be removed from that
130
+ folder (`cd` there and run `uninstall`).
95
131
 
96
132
  Uninstalling keeps your résumé, profile and application history in `~/Documents/Job Apply`.
97
133
  To erase those too, ask the assistant to "clear my Job Apply profile" before you uninstall,
@@ -103,8 +139,7 @@ or delete that folder.
103
139
  ## Other commands
104
140
 
105
141
  ```bash
106
- npx job-apply-plugin status # where it's installed and whether Python is OK
107
- npx job-apply-plugin install # run again to update
142
+ npx job-apply-plugin status # where it's installed, whether an update is out, and whether Python is OK
108
143
  ```
109
144
 
110
145
  ## Installing from the Git repository (alternative)
package/bin/job-apply.js CHANGED
@@ -22,6 +22,15 @@ const DEFAULT_MARKETPLACE = "local-plugins";
22
22
  // ---------- helpers ----------
23
23
 
24
24
  const home = () => os.homedir();
25
+ // True when two paths name the same folder, even through symlinks (e.g. /var vs /private/var on macOS).
26
+ const real = (p) => {
27
+ try {
28
+ return fs.realpathSync(p);
29
+ } catch {
30
+ return path.resolve(p);
31
+ }
32
+ };
33
+ const samePath = (a, b) => real(a) === real(b);
25
34
  const exists = (p) => fs.existsSync(p);
26
35
  const log = (msg = "") => process.stdout.write(msg + "\n");
27
36
 
@@ -117,6 +126,17 @@ const claude = {
117
126
  detect: () => exists(path.join(home(), ".claude")) || onPath("claude"),
118
127
  skillsDir: (opts) =>
119
128
  opts.project ? path.join(process.cwd(), ".claude", "skills") : path.join(home(), ".claude", "skills"),
129
+ // Where uninstall/status look: --project → only this folder; otherwise the user folder plus this folder's project install.
130
+ skillDirs(opts) {
131
+ if (opts.project) return [this.skillsDir(opts)];
132
+ const user = this.skillsDir({});
133
+ const project = path.join(process.cwd(), ".claude", "skills");
134
+ return samePath(project, user) || samePath(process.cwd(), home()) ? [user] : [user, project];
135
+ },
136
+ installedIn(base) {
137
+ const markers = skillNames().map((n) => readMarker(path.join(base, n)));
138
+ return markers.every(Boolean) ? markers[0].version : markers.some(Boolean) ? "partial" : null;
139
+ },
120
140
 
121
141
  install(opts) {
122
142
  const base = this.skillsDir(opts);
@@ -131,17 +151,25 @@ const claude = {
131
151
  },
132
152
 
133
153
  uninstall(opts) {
134
- const base = this.skillsDir(opts);
135
- const removed = skillNames().filter((name) => removeOwnedDir(path.join(base, name)));
136
- log(removed.length ? `✔ Claude Code: removed ${removed.join(", ")} from ${base}` : `• Claude Code: nothing to remove in ${base}`);
154
+ let any = false;
155
+ for (const base of this.skillDirs(opts)) {
156
+ const removed = skillNames().filter((name) => removeOwnedDir(path.join(base, name)));
157
+ if (removed.length) {
158
+ any = true;
159
+ log(`✔ Claude Code: removed ${removed.join(", ")} from ${base}`);
160
+ }
161
+ }
162
+ if (any) log(` Restart any open Claude Code sessions so they stop offering the skills.`);
163
+ else log(`• Claude Code: nothing to remove`);
137
164
  },
138
165
 
139
166
  status(opts) {
140
- const base = this.skillsDir(opts);
141
- const markers = skillNames().map((n) => readMarker(path.join(base, n)));
142
- if (markers.every(Boolean)) return `installed v${markers[0].version} (${base})`;
143
- if (markers.some(Boolean)) return `partially installed (${base}) — run install again`;
144
- return "not installed";
167
+ const found = this.skillDirs(opts)
168
+ .map((base) => [base, this.installedIn(base)])
169
+ .filter(([, v]) => v);
170
+ if (found.length === 0) return "not installed";
171
+ if (found.some(([, v]) => v === "partial")) return `partially installed (${found.map(([b]) => b).join(", ")}) — run install again`;
172
+ return `installed v${found[0][1]} (${found.map(([b]) => b).join(", ")})`;
145
173
  },
146
174
  };
147
175
 
@@ -184,12 +212,68 @@ const chatgpt = {
184
212
  if (i >= 0) log(` (Updating? Reinstall it from the Plugins page so ChatGPT refreshes its cached copy.)`);
185
213
  },
186
214
 
215
+ cacheRoot: () => path.join(home(), ".codex", "plugins", "cache"),
216
+ configFile: () => path.join(home(), ".codex", "config.toml"),
217
+
218
+ // ChatGPT keeps its own installed copy at cache/<marketplace>/<plugin>/<version>/.
219
+ // Only folders whose manifest is this plugin (same name and author) count as ours.
220
+ cachedCopies() {
221
+ const root = this.cacheRoot();
222
+ if (!exists(root)) return [];
223
+ const ours = (manifest) => {
224
+ try {
225
+ const m = JSON.parse(fs.readFileSync(manifest, "utf8"));
226
+ return m.name === PLUGIN_NAME && m.author?.name === PLUGIN.author?.name;
227
+ } catch {
228
+ return false;
229
+ }
230
+ };
231
+ const copies = [];
232
+ for (const market of fs.readdirSync(root, { withFileTypes: true })) {
233
+ if (!market.isDirectory()) continue;
234
+ const dir = path.join(root, market.name, PLUGIN_NAME);
235
+ if (!exists(dir)) continue;
236
+ const versions = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory());
237
+ const matches = versions.some((v) =>
238
+ ["plugin.json", path.join(".codex-plugin", "plugin.json")].some((m) => ours(path.join(dir, v.name, m)))
239
+ );
240
+ if (matches) copies.push({ market: market.name, dir });
241
+ }
242
+ return copies;
243
+ },
244
+
245
+ // Drops the [plugins."job-apply@<market>"] tables ChatGPT writes when the plugin is installed or toggled.
246
+ removeConfigEntries(markets) {
247
+ const file = this.configFile();
248
+ if (!exists(file) || markets.length === 0) return 0;
249
+ const headers = new Set(markets.map((m) => `[plugins."${PLUGIN_NAME}@${m}"]`));
250
+ const out = [];
251
+ let skipping = false;
252
+ let removed = 0;
253
+ for (const line of fs.readFileSync(file, "utf8").split(/(?<=\n)/)) {
254
+ const trimmed = line.trim();
255
+ if (trimmed.startsWith("[")) {
256
+ skipping = headers.has(trimmed.replace(/\s*#.*$/, ""));
257
+ if (skipping) removed++;
258
+ }
259
+ if (!skipping) out.push(line);
260
+ }
261
+ if (removed) {
262
+ const tmp = `${file}.${process.pid}.tmp`;
263
+ fs.writeFileSync(tmp, out.join(""));
264
+ fs.renameSync(tmp, file);
265
+ }
266
+ return removed;
267
+ },
268
+
187
269
  uninstall() {
188
270
  const removedDir = removeOwnedDir(this.pluginDir());
189
271
  const file = this.marketplaceFile();
190
272
  let removedEntry = false;
273
+ const markets = new Set([DEFAULT_MARKETPLACE]);
191
274
  if (exists(file)) {
192
275
  const market = readJson(file);
276
+ if (typeof market.name === "string") markets.add(market.name);
193
277
  const before = market.plugins?.length ?? 0;
194
278
  market.plugins = (market.plugins ?? []).filter((p) => p?.name !== PLUGIN_NAME);
195
279
  removedEntry = market.plugins.length !== before;
@@ -198,8 +282,20 @@ const chatgpt = {
198
282
  else writeJson(file, market);
199
283
  }
200
284
  }
201
- if (removedDir || removedEntry) {
202
- log(`✔ ChatGPT/Codex: removed ${PLUGIN_NAME}. Uninstall it from the ChatGPT Plugins page too, then restart the app.`);
285
+ const copies = this.cachedCopies();
286
+ for (const c of copies) {
287
+ fs.rmSync(c.dir, { recursive: true, force: true });
288
+ markets.add(c.market);
289
+ }
290
+ const settings = this.removeConfigEntries([...markets]);
291
+
292
+ if (removedDir || removedEntry || copies.length || settings) {
293
+ log(`✔ ChatGPT/Codex: removed ${PLUGIN_NAME}:`);
294
+ if (removedDir) log(` - plugin files in ${this.pluginDir()}`);
295
+ if (removedEntry) log(` - its entry in ${file}`);
296
+ for (const c of copies) log(` - ChatGPT's installed copy in ${c.dir}`);
297
+ if (settings) log(` - its settings in ${this.configFile()}`);
298
+ log(` Restart the ChatGPT desktop app to finish.`);
203
299
  } else {
204
300
  log(`• ChatGPT/Codex: nothing to remove`);
205
301
  }
@@ -214,12 +310,119 @@ const chatgpt = {
214
310
  } catch {}
215
311
  if (marker && listed) return `installed v${marker.version} (${this.pluginDir()})`;
216
312
  if (marker || listed) return "partially installed — run install again";
313
+ if (this.cachedCopies().length) return "leftover files from an earlier install — run uninstall to clean up";
217
314
  return "not installed";
218
315
  },
219
316
  };
220
317
 
221
318
  const TARGETS = { claude, chatgpt };
222
319
 
320
+ // ---------- versions & updates ----------
321
+
322
+ // Compares x.y.z versions numerically; prerelease tags are ignored. Returns <0, 0 or >0.
323
+ export function compareVersions(a, b) {
324
+ const pa = String(a).split("-")[0].split(".").map(Number);
325
+ const pb = String(b).split("-")[0].split(".").map(Number);
326
+ for (let i = 0; i < 3; i++) {
327
+ const d = (pa[i] || 0) - (pb[i] || 0);
328
+ if (d) return d;
329
+ }
330
+ return 0;
331
+ }
332
+
333
+ const FIRST_RELEASE = "0.1.0";
334
+ const registryUrl = (spec) =>
335
+ `${(process.env.npm_config_registry || "https://registry.npmjs.org/").replace(/\/?$/, "/")}${PKG.name}${spec ? `/${spec}` : ""}`;
336
+
337
+ async function registryGet(spec) {
338
+ const res = await fetch(registryUrl(spec), { signal: AbortSignal.timeout(8000), headers: { accept: "application/json" } });
339
+ if (res.status === 404) return null;
340
+ if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
341
+ return res.json();
342
+ }
343
+
344
+ async function latestVersion() {
345
+ return (await registryGet("latest"))?.version ?? null;
346
+ }
347
+
348
+ async function releasedVersions() {
349
+ const doc = await registryGet("");
350
+ return Object.keys(doc?.versions ?? {})
351
+ .filter((v) => /^\d+\.\d+\.\d+$/.test(v) && compareVersions(v, FIRST_RELEASE) >= 0)
352
+ .sort(compareVersions);
353
+ }
354
+
355
+ // Where the plugin is installed now, as install jobs that any released version understands.
356
+ function installedJobs(values, opts) {
357
+ const flagged = flagTargets(values);
358
+ const want = (t) => flagged.length === 0 || flagged.includes(t);
359
+ const jobs = [];
360
+ if (want(claude)) {
361
+ const userDir = claude.skillsDir({});
362
+ for (const base of claude.skillDirs(opts)) {
363
+ const v = claude.installedIn(base);
364
+ if (!v) continue;
365
+ const project = !samePath(base, userDir);
366
+ jobs.push({ target: claude, project, version: v, where: base });
367
+ }
368
+ }
369
+ if (want(chatgpt)) {
370
+ const v = readMarker(chatgpt.pluginDir())?.version;
371
+ if (v || chatgpt.status().startsWith("partially")) jobs.push({ target: chatgpt, project: false, version: v ?? "partial", where: chatgpt.pluginDir() });
372
+ }
373
+ return jobs;
374
+ }
375
+
376
+ function runOtherVersion(version, job) {
377
+ const args = ["-y", `${PKG.name}@${version}`, "install", job.target === claude ? "--claude" : "--chatgpt"];
378
+ if (job.project) args.push("--project");
379
+ const npx = process.env.JOB_APPLY_NPX || (process.platform === "win32" ? "npx.cmd" : "npx");
380
+ const r = spawnSync(npx, args, { stdio: "inherit", shell: process.platform === "win32" });
381
+ if (r.status !== 0) {
382
+ throw new CliError(`Installing ${PKG.name}@${version} failed. Try it directly:\n npx ${args.slice(1).join(" ")}`);
383
+ }
384
+ }
385
+
386
+ async function update(values, opts, requested) {
387
+ const jobs = installedJobs(values, opts);
388
+ if (jobs.length === 0) {
389
+ log(`Job Apply isn't installed${flagTargets(values).length ? " there" : ""}. Run "npx ${PKG.name}@latest install" first.`);
390
+ return;
391
+ }
392
+
393
+ let target = requested;
394
+ if (target) {
395
+ if (!/^\d+\.\d+\.\d+$/.test(target)) throw new CliError(`"${target}" isn't a version number. Use something like 0.3.0.`);
396
+ const versions = await releasedVersions().catch(() => null);
397
+ if (versions && !versions.includes(target)) {
398
+ throw new CliError(`Version ${target} doesn't exist. Available: ${versions.join(", ")}`);
399
+ }
400
+ } else {
401
+ try {
402
+ target = await latestVersion();
403
+ } catch (err) {
404
+ throw new CliError(`Couldn't reach npm to find the latest version (${err.message}). Check your connection and try again.`);
405
+ }
406
+ if (!target) throw new CliError(`Couldn't find ${PKG.name} on npm.`);
407
+ }
408
+
409
+ const current = [...new Set(jobs.map((j) => j.version))].join(", ");
410
+ const direction = jobs.every((j) => j.version === target)
411
+ ? "Reinstalling"
412
+ : compareVersions(target, jobs[0].version === "partial" ? "0.0.0" : jobs[0].version) < 0
413
+ ? "Switching back to"
414
+ : "Updating to";
415
+ log(`${direction} v${target} (installed: v${current}) in ${jobs.map((j) => j.target.label + (j.project ? " (this project)" : "")).join(" and ")}.`);
416
+ log();
417
+
418
+ for (const job of jobs) {
419
+ if (target === PKG.version) job.target.install({ ...opts, project: job.project });
420
+ else runOtherVersion(target, job);
421
+ }
422
+ log();
423
+ log(`Done. Your résumé, profile and history in ~/Documents/Job Apply are unchanged.`);
424
+ }
425
+
223
426
  // ---------- CLI ----------
224
427
 
225
428
  const HELP = `${PKG.name} v${PKG.version}
@@ -231,10 +434,12 @@ Usage
231
434
  yarn dlx ${PKG.name} <command> [options]
232
435
 
233
436
  Commands
234
- install Install (or update) the plugin
437
+ install Install the plugin (asks Claude Code, ChatGPT or both)
438
+ update Update wherever it's installed to the latest version
439
+ update <v> Switch to a specific version, e.g. "update 0.3.0" (also goes back to older ones)
235
440
  uninstall Remove the plugin from Claude Code, ChatGPT or both (alias: remove).
236
441
  Your profile and history in ~/Documents/Job Apply are kept.
237
- status Show where the plugin is installed and check Python
442
+ status Show where it's installed, whether an update is available, and check Python
238
443
 
239
444
  Options
240
445
  --claude Claude Code only
@@ -385,10 +590,21 @@ export async function main(argv = process.argv.slice(2)) {
385
590
  log();
386
591
  log(`Your résumé, profile and history in ~/Documents/Job Apply were kept.`);
387
592
  log(`To erase them too, delete that folder or ask the assistant to "clear my Job Apply profile" before uninstalling.`);
593
+ } else if (command === "update" || command === "upgrade") {
594
+ await update(values, opts, positionals[1]);
388
595
  } else if (command === "status") {
389
596
  for (const t of Object.values(TARGETS)) log(`${t.label.padEnd(24)} ${t.status(opts)}`);
390
597
  const py = pythonCheck();
391
598
  log(`${"Python 3.9+".padEnd(24)} ${py.ok ? `ok (${py.version})` : py.version ? `too old (${py.version})` : "not found"}`);
599
+ const latest = await latestVersion().catch(() => undefined);
600
+ const installed = installedJobs({}, opts).map((j) => j.version).filter((v) => v !== "partial");
601
+ let line;
602
+ if (latest === undefined) line = "couldn't check npm (offline?)";
603
+ else if (!latest) line = "not found on npm";
604
+ else if (installed.some((v) => compareVersions(v, latest) < 0))
605
+ line = `v${latest} available — run: npx ${PKG.name}@latest update`;
606
+ else line = `v${latest}${installed.length ? " (up to date)" : ""}`;
607
+ log(`${"Latest version".padEnd(24)} ${line}`);
392
608
  } else {
393
609
  throw new CliError(`Unknown command "${command}". Run with --help.`);
394
610
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-apply-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Job Apply plugin for Claude Code and ChatGPT/Codex: prepare and submit job applications from a résumé and profile saved on your device.",
5
5
  "keywords": [
6
6
  "claude",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-apply",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Prepare and submit individual job applications using a résumé and profile saved on your device.",
5
5
  "author": {
6
6
  "name": "Filipe Linemburger"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "job-apply",
4
- "version": "0.3.0",
4
+ "version": "0.5.0",
5
5
  "description": "Prepare and submit individual job applications using a résumé and profile saved on your device.",
6
6
  "keywords": [
7
7
  "jobs",