@bagthejobai/apply-agent 1.23.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 ADDED
@@ -0,0 +1,39 @@
1
+ # @bagthejobai/apply-agent
2
+
3
+ A tiny command-line helper that fetches the current [BagTheJob.ai](https://app.bagthejob.ai) application-agent skill to your clipboard so you can paste it into the Claude desktop app.
4
+
5
+ ```bash
6
+ npx @bagthejobai/apply-agent setup # first-time install
7
+ npx @bagthejobai/apply-agent update # fetch the latest to re-paste
8
+ ```
9
+
10
+ ## What it does
11
+
12
+ - Fetches the skill live from `GET https://app.bagthejob.ai/skill`.
13
+ - Copies it to your clipboard (macOS `pbcopy`, Windows `clip`, Linux `wl-copy`/`xclip`/`xsel`).
14
+ - Prints short instructions for reviewing and pasting it.
15
+
16
+ ## What it deliberately does NOT do
17
+
18
+ This is a **fetch-and-paste helper, not an installer.** By design (see the project's issue #159 security model):
19
+
20
+ - It never writes the skill to a file on your machine.
21
+ - It never creates or edits `config.json`, `answers.json`, or your resume.
22
+ - It never registers a scheduled task or runs anything on your behalf.
23
+
24
+ **You** are the install step: review the skill you copied, then paste it into the Claude desktop app yourself. That human review is the security gate — nothing is fetched and silently executed.
25
+
26
+ ## Options
27
+
28
+ | Flag | Meaning |
29
+ |------|---------|
30
+ | `--base-url <url>` | Fetch from another server (default `https://app.bagthejob.ai`). Also settable via `BTJ_BASE_URL`. |
31
+ | `--stdout` | Print the skill to the terminal instead of using the clipboard (headless/CI). |
32
+ | `-h`, `--help` | Show help. |
33
+ | `-v`, `--version` | Show the CLI version. |
34
+
35
+ If no clipboard tool is available, the CLI automatically prints the full skill between `BEGIN SKILL` / `END SKILL` markers so you can copy it manually.
36
+
37
+ ## Versioning
38
+
39
+ The CLI ships **no** skill content — it always fetches the live copy, so it can never go stale relative to the server. The package version is cut to match the skill's `Template version` at publish time (e.g. `1.23.0` for skill `v1.23.0`), so the number you install signals which skill release it shipped alongside. What you actually receive is always whatever the server currently serves.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { run } from "../lib/cli.js";
4
+
5
+ // Read the package version at runtime so `--version` always matches package.json
6
+ // (which is cut to the skill's Template version) — no second copy to keep in sync.
7
+ const require = createRequire(import.meta.url);
8
+ const { version } = require("../package.json");
9
+
10
+ process.exit(await run(process.argv.slice(2), { pkgVersion: version }));
package/lib/cli.js ADDED
@@ -0,0 +1,263 @@
1
+ // @bagthejobai/apply-agent — a fetch-and-paste helper for the BagTheJob
2
+ // application-agent skill.
3
+ //
4
+ // What it does: fetches the current skill (`GET {base}/skill`) and puts it on
5
+ // your clipboard, then prints instructions for you to review and paste it into
6
+ // the Claude desktop app yourself.
7
+ //
8
+ // What it deliberately does NOT do (issue #159): it never writes the skill to a
9
+ // file, never creates `config.json`, and never registers a scheduled task. The
10
+ // human reviewing and pasting the skill is the security gate; this tool only
11
+ // moves text to your clipboard. It vendors no skill content — the server copy
12
+ // (go:embed-ed into btj-api, served at /skill) stays the single source of truth,
13
+ // so the npm package can never drift from the deployed skill.
14
+
15
+ import { spawn } from "node:child_process";
16
+
17
+ export const DEFAULT_BASE_URL = "https://app.bagthejob.ai";
18
+
19
+ // resolveBaseUrl picks the server to fetch from. Precedence: explicit --base-url
20
+ // flag > BTJ_BASE_URL env > the production default. Trailing slashes are stripped
21
+ // so `${base}/skill` never doubles up.
22
+ export function resolveBaseUrl({ flag, env } = {}) {
23
+ const raw = (flag ?? env ?? DEFAULT_BASE_URL).trim();
24
+ const chosen = raw === "" ? DEFAULT_BASE_URL : raw;
25
+ return chosen.replace(/\/+$/, "");
26
+ }
27
+
28
+ // SkillFetchError carries a human-facing message; the CLI prints .message and
29
+ // exits 1 without a stack trace.
30
+ export class SkillFetchError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "SkillFetchError";
34
+ }
35
+ }
36
+
37
+ // fetchSkill GETs {base}/skill and validates the {version, content} shape. It
38
+ // throws SkillFetchError (never a raw network/JSON error) with a message that
39
+ // names the URL and the reason, so a caller only has to print .message.
40
+ export async function fetchSkill(baseUrl, fetchImpl = fetch) {
41
+ const url = `${baseUrl}/skill`;
42
+ const fail = (reason) =>
43
+ new SkillFetchError(
44
+ `Could not fetch the skill from ${url}: ${reason}. ` +
45
+ `Check your connection, or pass --base-url for a local server.`,
46
+ );
47
+
48
+ let res;
49
+ try {
50
+ res = await fetchImpl(url, {
51
+ method: "GET",
52
+ headers: { accept: "application/json" },
53
+ });
54
+ } catch (err) {
55
+ throw fail(err?.message || "network error");
56
+ }
57
+
58
+ if (!res.ok) {
59
+ throw fail(`server returned HTTP ${res.status}`);
60
+ }
61
+
62
+ let body;
63
+ try {
64
+ body = await res.json();
65
+ } catch {
66
+ // A reverse proxy or captive portal can answer 200 with an HTML error page.
67
+ throw fail("response was not valid JSON (unexpected server or proxy page)");
68
+ }
69
+
70
+ const version = body?.version;
71
+ const content = body?.content;
72
+ if (typeof version !== "string" || version.trim() === "") {
73
+ throw fail("response is missing a 'version' field");
74
+ }
75
+ if (typeof content !== "string" || content.trim() === "") {
76
+ throw fail("response is missing a 'content' field");
77
+ }
78
+ return { version: version.trim(), content };
79
+ }
80
+
81
+ // clipboardCommandFor returns an ordered list of candidate clipboard commands
82
+ // (argv arrays) for a platform. Each is tried in order until one succeeds, so a
83
+ // Linux box with wl-copy OR xclip OR xsel all work.
84
+ export function clipboardCommandFor(platform) {
85
+ if (platform === "darwin") return [["pbcopy"]];
86
+ if (platform === "win32") return [["clip"]];
87
+ return [
88
+ ["wl-copy"],
89
+ ["xclip", "-selection", "clipboard"],
90
+ ["xsel", "--clipboard", "--input"],
91
+ ];
92
+ }
93
+
94
+ // copyToClipboard writes text to the first working platform clipboard command.
95
+ // Returns true on success, false if no command exists / all fail — it never
96
+ // throws, so a headless box just falls through to stdout.
97
+ export function copyToClipboard(text, platform = process.platform) {
98
+ const candidates = clipboardCommandFor(platform);
99
+ return candidates.reduce(
100
+ (chain, argv) => chain.then((done) => (done ? true : trySpawnCopy(argv, text))),
101
+ Promise.resolve(false),
102
+ );
103
+ }
104
+
105
+ function trySpawnCopy([cmd, ...args], text) {
106
+ return new Promise((resolve) => {
107
+ let child;
108
+ try {
109
+ child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
110
+ } catch {
111
+ resolve(false);
112
+ return;
113
+ }
114
+ child.on("error", () => resolve(false));
115
+ child.on("close", (code) => resolve(code === 0));
116
+ try {
117
+ child.stdin.on("error", () => resolve(false));
118
+ child.stdin.end(text);
119
+ } catch {
120
+ resolve(false);
121
+ }
122
+ });
123
+ }
124
+
125
+ const DASHBOARD_URL = "/dashboard";
126
+ const BEGIN_MARK = "─── BEGIN SKILL ───";
127
+ const END_MARK = "─── END SKILL ───";
128
+
129
+ // instructionsFor builds the human-facing message (pure — no I/O). On clipboard
130
+ // success the skill content is NOT echoed (it's already on the clipboard); on
131
+ // fallback the full content is fenced between BEGIN/END markers so the user can
132
+ // select it manually. The instructions always lead with "review the skill" —
133
+ // the #159 gate — and never reference any local file the tool would write.
134
+ export function instructionsFor(command, { version, copied, content, baseUrl, stdout }) {
135
+ const origin = baseUrl ?? DEFAULT_BASE_URL;
136
+ const lines = [];
137
+ const isUpdate = command === "update";
138
+
139
+ lines.push(
140
+ isUpdate
141
+ ? `Latest skill Template version ${version} fetched from ${origin}`
142
+ : `Fetched skill Template version ${version} from ${origin}`,
143
+ );
144
+ lines.push(
145
+ copied
146
+ ? "✓ Copied to your clipboard."
147
+ : stdout
148
+ ? "Printing the skill below (--stdout)."
149
+ : "Clipboard unavailable — full skill printed below.",
150
+ );
151
+ lines.push("");
152
+
153
+ if (!copied) {
154
+ lines.push(BEGIN_MARK);
155
+ lines.push(content ?? "");
156
+ lines.push(END_MARK);
157
+ lines.push("");
158
+ }
159
+
160
+ if (isUpdate) {
161
+ lines.push("This tool installs nothing. You review and paste the update yourself:");
162
+ lines.push(" 1. Review the skill above — you should always know what your agent runs.");
163
+ lines.push(" 2. Open the Claude desktop app and find your `daily-job-application` task.");
164
+ lines.push(" 3. Paste this skill over the existing task description — a clean replace.");
165
+ lines.push(" Your local config in references/ is untouched.");
166
+ lines.push(" 4. Re-register the task, then re-run it.");
167
+ } else {
168
+ lines.push("This tool installs nothing. You are the install step:");
169
+ lines.push(" 1. Review the skill you just copied — you should always know what your agent runs.");
170
+ lines.push(" 2. Open the Claude desktop app (with its Chrome connector enabled).");
171
+ lines.push(" 3. Paste the skill into a new `daily-job-application` scheduled-task description.");
172
+ lines.push(` 4. On first run, Claude walks you through setup and asks for your API key`);
173
+ lines.push(` (create one on your dashboard: ${origin}${DASHBOARD_URL}).`);
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ const USAGE = `apply-agent — fetch the BagTheJob application-agent skill to your clipboard.
179
+
180
+ Usage:
181
+ npx @bagthejobai/apply-agent setup Fetch the skill and copy it for first-time install
182
+ npx @bagthejobai/apply-agent update Fetch the latest skill and copy it to re-paste
183
+
184
+ Options:
185
+ --base-url <url> Override the server (default: ${DEFAULT_BASE_URL}; or set BTJ_BASE_URL)
186
+ --stdout Print the skill instead of using the clipboard (headless/CI)
187
+ -h, --help Show this help
188
+ -v, --version Show the CLI version
189
+
190
+ This tool never writes files, never stores credentials, and never registers a
191
+ task. It only puts the skill on your clipboard for you to review and paste.`;
192
+
193
+ // parseArgs is a tiny flag parser (pure) — no dependency on a parsing library.
194
+ export function parseArgs(argv) {
195
+ const opts = { command: undefined, baseUrlFlag: undefined, stdout: false, help: false, version: false };
196
+ for (let i = 0; i < argv.length; i++) {
197
+ const a = argv[i];
198
+ if (a === "--help" || a === "-h") opts.help = true;
199
+ else if (a === "--version" || a === "-v") opts.version = true;
200
+ else if (a === "--stdout") opts.stdout = true;
201
+ else if (a === "--base-url") opts.baseUrlFlag = argv[++i];
202
+ else if (a.startsWith("--base-url=")) opts.baseUrlFlag = a.slice("--base-url=".length);
203
+ else if (!a.startsWith("-") && opts.command === undefined) opts.command = a;
204
+ else opts.command = opts.command ?? a;
205
+ }
206
+ return opts;
207
+ }
208
+
209
+ // run is the injectable entry point. Returns a process exit code (0 ok, 1 error)
210
+ // rather than calling process.exit, so tests can assert on it directly.
211
+ export async function run(
212
+ argv,
213
+ {
214
+ fetchImpl = fetch,
215
+ platform = process.platform,
216
+ env = process.env,
217
+ out = (s) => process.stdout.write(s + "\n"),
218
+ err = (s) => process.stderr.write(s + "\n"),
219
+ pkgVersion = "0.1.0",
220
+ } = {},
221
+ ) {
222
+ const opts = parseArgs(argv);
223
+
224
+ if (opts.help) {
225
+ out(USAGE);
226
+ return 0;
227
+ }
228
+ if (opts.version) {
229
+ out(pkgVersion);
230
+ return 0;
231
+ }
232
+ if (opts.command !== "setup" && opts.command !== "update") {
233
+ err(opts.command ? `Unknown command: ${opts.command}\n` : "No command given.\n");
234
+ err(USAGE);
235
+ return 1;
236
+ }
237
+
238
+ const baseUrl = resolveBaseUrl({ flag: opts.baseUrlFlag, env: env.BTJ_BASE_URL });
239
+
240
+ let skill;
241
+ try {
242
+ skill = await fetchSkill(baseUrl, fetchImpl);
243
+ } catch (e) {
244
+ err(e.message);
245
+ return 1;
246
+ }
247
+
248
+ let copied = false;
249
+ if (!opts.stdout) {
250
+ copied = await copyToClipboard(skill.content, platform);
251
+ }
252
+
253
+ out(
254
+ instructionsFor(opts.command, {
255
+ version: skill.version,
256
+ copied,
257
+ content: skill.content,
258
+ baseUrl,
259
+ stdout: opts.stdout,
260
+ }),
261
+ );
262
+ return 0;
263
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@bagthejobai/apply-agent",
3
+ "version": "1.23.0",
4
+ "description": "Fetch the latest BagTheJob application-agent skill to your clipboard for manual paste into Claude. Fetch-and-paste helper only — never installs, writes, or registers anything.",
5
+ "type": "module",
6
+ "bin": {
7
+ "apply-agent": "./bin/apply-agent.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "license": "MIT",
18
+ "homepage": "https://app.bagthejob.ai",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/richkuo/job-search.git",
22
+ "directory": "apply-agent-cli"
23
+ },
24
+ "keywords": [
25
+ "bagthejob",
26
+ "job-application",
27
+ "claude",
28
+ "cli"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }