job-apply-plugin 0.2.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.2.0",
12
+ "version": "0.5.0",
13
13
  "category": "productivity"
14
14
  }
15
15
  ]
package/README.md CHANGED
@@ -74,24 +74,80 @@ 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
- ## Other commands
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:
78
87
 
79
88
  ```bash
80
- npx job-apply-plugin status # where it's installed and whether Python is OK
81
- npx job-apply-plugin install # run again to update
82
- npx job-apply-plugin uninstall # removes it from both apps and keeps your profile and history
83
- npx job-apply-plugin uninstall --chatgpt # or from just one app (--claude / --chatgpt)
89
+ npx job-apply-plugin@latest update 0.3.0
84
90
  ```
85
91
 
86
- To erase your saved profile and history, ask the assistant to "clear my Job Apply profile",
87
- or delete `~/Documents/Job Apply`.
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
+
103
+ ## Uninstall
104
+
105
+ ```bash
106
+ npx job-apply-plugin uninstall
107
+ ```
108
+
109
+ It asks which app to remove Job Apply from, and shows where it's currently installed:
110
+
111
+ ```
112
+ Remove Job Apply from which app?
113
+ 1) Claude Code (installed v0.5.0)
114
+ 2) ChatGPT desktop / Codex (not installed)
115
+ 3) Both
116
+ Choose 1, 2 or 3 [1]:
117
+ ```
118
+
119
+ To skip the menu, use `--claude`, `--chatgpt` or `--all`. `remove` works as another name for `uninstall`.
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`).
131
+
132
+ Uninstalling keeps your résumé, profile and application history in `~/Documents/Job Apply`.
133
+ To erase those too, ask the assistant to "clear my Job Apply profile" before you uninstall,
134
+ or delete that folder.
135
+
136
+ > `npm uninstall -g job-apply-plugin` only removes the installer, not the plugin.
137
+ > Run `uninstall` first.
138
+
139
+ ## Other commands
140
+
141
+ ```bash
142
+ npx job-apply-plugin status # where it's installed, whether an update is out, and whether Python is OK
143
+ ```
88
144
 
89
145
  ## Installing from the Git repository (alternative)
90
146
 
91
147
  The repository is also a plugin marketplace for both apps.
92
148
 
93
- - Claude Code: `claude plugin marketplace add <owner>/<repo>` then `claude plugin install job-apply@job-apply`
94
- - Codex: `codex plugin marketplace add <path-or-repo>`
149
+ - Claude Code: `claude plugin marketplace add filipelinemburger/job-apply` then `claude plugin install job-apply@job-apply`
150
+ - Codex: `codex plugin marketplace add https://github.com/filipelinemburger/job-apply.git`
95
151
 
96
152
  ## Privacy
97
153
 
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,9 +434,12 @@ Usage
231
434
  yarn dlx ${PKG.name} <command> [options]
232
435
 
233
436
  Commands
234
- install Install (or update) the plugin
235
- uninstall Remove the plugin (your profile and history in ~/Documents/Job Apply are kept)
236
- status Show where the plugin is installed and check Python
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)
440
+ uninstall Remove the plugin from Claude Code, ChatGPT or both (alias: remove).
441
+ Your profile and history in ~/Documents/Job Apply are kept.
442
+ status Show where it's installed, whether an update is available, and check Python
237
443
 
238
444
  Options
239
445
  --claude Claude Code only
@@ -246,7 +452,7 @@ Options
246
452
 
247
453
  Run "install" with no app flag to pick from a menu: Claude Code, ChatGPT, or both.
248
454
  Without a terminal to ask in (CI, scripts), it installs into the apps found on this computer.
249
- "uninstall" with no app flag removes the plugin from both apps.`;
455
+ "uninstall" with no app flag shows the same menu (without a terminal, it removes from both).`;
250
456
 
251
457
  const CHOICES = {
252
458
  1: ["claude"],
@@ -271,16 +477,16 @@ function flagTargets(values) {
271
477
  return chosen;
272
478
  }
273
479
 
274
- async function askTargets(found) {
275
- const defaultChoice = found.length === 1 ? (found[0] === claude ? "1" : "2") : "3";
276
- const note = (t) => (found.includes(t) ? " (found on this computer)" : "");
277
- log("Where should Job Apply be installed?");
278
- log(` 1) Claude Code${note(claude)}`);
279
- log(` 2) ChatGPT desktop / Codex${note(chatgpt)}`);
480
+ // Shows the 1/2/3 menu. `notes` maps each target to a hint shown next to it; `verb` is "installed" or "removed".
481
+ async function askTargets({ title, notes, defaultChoice, verb }) {
482
+ const cancelled = `Cancelled. Nothing was ${verb}.`;
483
+ log(title);
484
+ log(` 1) Claude Code${notes(claude)}`);
485
+ log(` 2) ChatGPT desktop / Codex${notes(chatgpt)}`);
280
486
  log(` 3) Both`);
281
487
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
282
488
  rl.on("SIGINT", () => {
283
- log("\nCancelled. Nothing was installed.");
489
+ log(`\n${cancelled}`);
284
490
  process.exit(130);
285
491
  });
286
492
  try {
@@ -290,7 +496,7 @@ async function askTargets(found) {
290
496
  answer = await rl.question(`Choose 1, 2 or 3 [${defaultChoice}]: `);
291
497
  } catch (err) {
292
498
  // Ctrl+D or a closed terminal
293
- if (err?.name === "AbortError" || err?.code === "ABORT_ERR") throw new CliError("Cancelled. Nothing was installed.");
499
+ if (err?.name === "AbortError" || err?.code === "ABORT_ERR") throw new CliError(cancelled);
294
500
  throw err;
295
501
  }
296
502
  const ids = parseChoice(answer, defaultChoice);
@@ -303,20 +509,49 @@ async function askTargets(found) {
303
509
  } finally {
304
510
  rl.close();
305
511
  }
306
- throw new CliError("No choice made. Nothing was installed.");
512
+ throw new CliError(`No choice made. Nothing was ${verb}.`);
307
513
  }
308
514
 
515
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
516
+ const choiceFor = (list) => (list.length === 1 ? (list[0] === claude ? "1" : "2") : "3");
517
+
309
518
  async function pickInstallTargets(values) {
310
519
  const flagged = flagTargets(values);
311
520
  if (flagged.length) return flagged;
312
521
  const found = Object.values(TARGETS).filter((t) => t.detect());
313
- if (process.stdin.isTTY && process.stdout.isTTY) return askTargets(found);
522
+ if (interactive()) {
523
+ return askTargets({
524
+ title: "Where should Job Apply be installed?",
525
+ notes: (t) => (found.includes(t) ? " (found on this computer)" : ""),
526
+ defaultChoice: choiceFor(found),
527
+ verb: "installed",
528
+ });
529
+ }
314
530
  // No terminal to ask in: fall back to the apps found here (both if none).
315
531
  const targets = found.length ? found : Object.values(TARGETS);
316
532
  log(`No app chosen; installing for ${targets.map((t) => t.label).join(" and ")}. Use --claude, --chatgpt or --all to choose.`);
317
533
  return targets;
318
534
  }
319
535
 
536
+ // Returns [] when there is nothing to remove.
537
+ async function pickUninstallTargets(values, opts) {
538
+ const flagged = flagTargets(values);
539
+ if (flagged.length) return flagged;
540
+ const statuses = new Map(Object.values(TARGETS).map((t) => [t, t.status(opts)]));
541
+ const installed = [...statuses].filter(([, s]) => s !== "not installed").map(([t]) => t);
542
+ if (installed.length === 0) {
543
+ log("Job Apply isn't installed in Claude Code or ChatGPT on this computer. Nothing to remove.");
544
+ return [];
545
+ }
546
+ if (!interactive()) return Object.values(TARGETS);
547
+ return askTargets({
548
+ title: "Remove Job Apply from which app?",
549
+ notes: (t) => ` (${statuses.get(t).replace(/ \(.*$/, "").replace(/ — .*$/, "")})`,
550
+ defaultChoice: choiceFor(installed),
551
+ verb: "removed",
552
+ });
553
+ }
554
+
320
555
  export async function main(argv = process.argv.slice(2)) {
321
556
  const { values, positionals } = parseArgs({
322
557
  args: argv,
@@ -348,14 +583,28 @@ export async function main(argv = process.argv.slice(2)) {
348
583
  }
349
584
  log();
350
585
  log(`Your résumé, profile and application history are stored locally in ~/Documents/Job Apply.`);
351
- } else if (command === "uninstall") {
352
- const flagged = flagTargets(values);
353
- for (const t of flagged.length ? flagged : Object.values(TARGETS)) t.uninstall(opts);
354
- log(`Your local profile in ~/Documents/Job Apply was not touched.`);
586
+ } else if (command === "uninstall" || command === "remove") {
587
+ const targets = await pickUninstallTargets(values, opts);
588
+ if (targets.length === 0) return;
589
+ for (const t of targets) t.uninstall(opts);
590
+ log();
591
+ log(`Your résumé, profile and history in ~/Documents/Job Apply were kept.`);
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]);
355
595
  } else if (command === "status") {
356
596
  for (const t of Object.values(TARGETS)) log(`${t.label.padEnd(24)} ${t.status(opts)}`);
357
597
  const py = pythonCheck();
358
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}`);
359
608
  } else {
360
609
  throw new CliError(`Unknown command "${command}". Run with --help.`);
361
610
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-apply-plugin",
3
- "version": "0.2.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",
@@ -14,6 +14,14 @@
14
14
  "resume"
15
15
  ],
16
16
  "author": "Filipe Linemburger",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/filipelinemburger/job-apply.git"
20
+ },
21
+ "homepage": "https://github.com/filipelinemburger/job-apply#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/filipelinemburger/job-apply/issues"
24
+ },
17
25
  "type": "module",
18
26
  "bin": {
19
27
  "job-apply-plugin": "bin/job-apply.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-apply",
3
- "version": "0.2.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.2.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",