@ha7ch/cv-pro 0.5.1 → 0.5.2
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 +14 -14
- package/dist/api.js +46 -10
- package/dist/index.js +67 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Follow these steps exactly, in order. No browser required.
|
|
|
17
17
|
**Step 2 — Register and log in (one command)**
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
npx cv-pro@latest register {handle}
|
|
20
|
+
npx @ha7ch/cv-pro@latest register {handle}
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
This claims the handle and saves the token automatically. Output: `Logged in as @{handle}`.
|
|
@@ -30,7 +30,7 @@ Ask: *Do you have a resume PDF or any content to publish now?*
|
|
|
30
30
|
|
|
31
31
|
- **PDF** — read the file, extract all fields into JSON, save to `/tmp/resume.json`, then:
|
|
32
32
|
```bash
|
|
33
|
-
npx cv-pro@latest update /tmp/resume.json
|
|
33
|
+
npx @ha7ch/cv-pro@latest update /tmp/resume.json
|
|
34
34
|
```
|
|
35
35
|
- **Text / paste** — same flow, extract from pasted content.
|
|
36
36
|
- **Later** — skip. The page is already live with an empty template.
|
|
@@ -38,7 +38,7 @@ Ask: *Do you have a resume PDF or any content to publish now?*
|
|
|
38
38
|
**Step 4 — Confirm**
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
npx cv-pro@latest whoami
|
|
41
|
+
npx @ha7ch/cv-pro@latest whoami
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Tell the user their live URL: **https://cv.ha7ch.com/{handle}**
|
|
@@ -52,15 +52,15 @@ Each `experience` and `projectsDetailed` entry takes a `bullets: string[]` field
|
|
|
52
52
|
## CLI reference
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
npx cv-pro@latest register <handle> # claim handle + auto-login (no browser)
|
|
56
|
-
npx cv-pro@latest login cv_pat_... # save an existing token
|
|
57
|
-
npx cv-pro@latest whoami # show handle + page URL
|
|
58
|
-
npx cv-pro@latest get # print current resume JSON
|
|
59
|
-
npx cv-pro@latest get --variant=openai # print one stored variant JSON
|
|
60
|
-
npx cv-pro@latest update resume.json # replace full resume
|
|
61
|
-
npx cv-pro@latest update-section <section> data.json
|
|
62
|
-
npx cv-pro@latest open # open live page in browser
|
|
63
|
-
npx cv-pro@latest open --json # open the public JSON view
|
|
55
|
+
npx @ha7ch/cv-pro@latest register <handle> # claim handle + auto-login (no browser)
|
|
56
|
+
npx @ha7ch/cv-pro@latest login cv_pat_... # save an existing token
|
|
57
|
+
npx @ha7ch/cv-pro@latest whoami # show handle + page URL
|
|
58
|
+
npx @ha7ch/cv-pro@latest get # print current resume JSON
|
|
59
|
+
npx @ha7ch/cv-pro@latest get --variant=openai # print one stored variant JSON
|
|
60
|
+
npx @ha7ch/cv-pro@latest update resume.json # replace full resume
|
|
61
|
+
npx @ha7ch/cv-pro@latest update-section <section> data.json
|
|
62
|
+
npx @ha7ch/cv-pro@latest open # open live page in browser
|
|
63
|
+
npx @ha7ch/cv-pro@latest open --json # open the public JSON view
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
## Public JSON URL
|
|
@@ -105,7 +105,7 @@ claude mcp add cv --transport http https://cv.ha7ch.com/api/mcp \
|
|
|
105
105
|
}
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
Tools: `get_resume` · `update_resume` · `update_section`
|
|
108
|
+
Tools: `get_schema` · `get_resume` · `update_resume` · `update_section` · `list_variants` · `get_variant` · `set_variant` · `delete_variant`
|
|
109
109
|
|
|
110
110
|
## Troubleshooting
|
|
111
111
|
|
|
@@ -114,4 +114,4 @@ Tools: `get_resume` · `update_resume` · `update_section`
|
|
|
114
114
|
## Links
|
|
115
115
|
|
|
116
116
|
- Site: [cv.ha7ch.com](https://cv.ha7ch.com)
|
|
117
|
-
- Repo: [github.com/
|
|
117
|
+
- Repo: [github.com/HA7CH/cv-pro](https://github.com/HA7CH/cv-pro)
|
package/dist/api.js
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
async function parseJson(res) {
|
|
2
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
3
|
+
const raw = await res.text().catch(() => "");
|
|
4
|
+
if (!ct.toLowerCase().includes("json")) {
|
|
5
|
+
throw new Error(`Server returned non-JSON (status ${res.status}): ${raw.slice(0, 200)}`);
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`Server returned non-JSON (status ${res.status}): ${raw.slice(0, 200)}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
1
14
|
async function throwServerError(res) {
|
|
2
|
-
const
|
|
15
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
16
|
+
const raw = await res.text().catch(() => "");
|
|
17
|
+
if (!ct.toLowerCase().includes("json")) {
|
|
18
|
+
throw new Error(`Server returned non-JSON (status ${res.status}): ${raw.slice(0, 200)}`);
|
|
19
|
+
}
|
|
20
|
+
let body = {};
|
|
21
|
+
try {
|
|
22
|
+
body = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// fall through with empty body
|
|
26
|
+
}
|
|
3
27
|
let msg = body.error ?? res.statusText;
|
|
4
28
|
if (body.issues?.length) {
|
|
5
29
|
msg +=
|
|
@@ -51,7 +75,7 @@ export async function register(handle, apiBase) {
|
|
|
51
75
|
body: JSON.stringify({ handle }),
|
|
52
76
|
});
|
|
53
77
|
checkEgressBlock({ token: "", apiBase }, res);
|
|
54
|
-
const data = await res
|
|
78
|
+
const data = await parseJson(res);
|
|
55
79
|
if (!res.ok)
|
|
56
80
|
throw new Error(data.error ?? res.statusText);
|
|
57
81
|
return { handle: data.handle, token: data.token };
|
|
@@ -65,14 +89,14 @@ export async function whoami(cfg) {
|
|
|
65
89
|
}
|
|
66
90
|
if (!res.ok)
|
|
67
91
|
return { ok: false, reason: "error" };
|
|
68
|
-
const data = await res
|
|
92
|
+
const data = await parseJson(res);
|
|
69
93
|
return { ok: true, username: data.username };
|
|
70
94
|
}
|
|
71
95
|
export async function getResume(cfg) {
|
|
72
96
|
const res = await req(cfg, "/api/v1/resume");
|
|
73
97
|
if (!res.ok)
|
|
74
98
|
await throwServerError(res);
|
|
75
|
-
return res
|
|
99
|
+
return parseJson(res);
|
|
76
100
|
}
|
|
77
101
|
export async function putResume(cfg, data) {
|
|
78
102
|
const res = await req(cfg, "/api/v1/resume", {
|
|
@@ -81,7 +105,7 @@ export async function putResume(cfg, data) {
|
|
|
81
105
|
});
|
|
82
106
|
if (!res.ok)
|
|
83
107
|
await throwServerError(res);
|
|
84
|
-
return res
|
|
108
|
+
return parseJson(res);
|
|
85
109
|
}
|
|
86
110
|
export async function patchSection(cfg, section, value) {
|
|
87
111
|
const res = await req(cfg, "/api/v1/resume", {
|
|
@@ -90,26 +114,26 @@ export async function patchSection(cfg, section, value) {
|
|
|
90
114
|
});
|
|
91
115
|
if (!res.ok)
|
|
92
116
|
await throwServerError(res);
|
|
93
|
-
return res
|
|
117
|
+
return parseJson(res);
|
|
94
118
|
}
|
|
95
119
|
export async function getSchema(apiBase) {
|
|
96
120
|
const res = await fetch(`${apiBase}/api/v1/schema`);
|
|
97
121
|
checkEgressBlock({ token: "", apiBase }, res);
|
|
98
122
|
if (!res.ok)
|
|
99
123
|
await throwServerError(res);
|
|
100
|
-
return res
|
|
124
|
+
return parseJson(res);
|
|
101
125
|
}
|
|
102
126
|
export async function listVariants(cfg) {
|
|
103
127
|
const res = await req(cfg, "/api/v1/variants");
|
|
104
128
|
if (!res.ok)
|
|
105
129
|
await throwServerError(res);
|
|
106
|
-
return res
|
|
130
|
+
return parseJson(res);
|
|
107
131
|
}
|
|
108
132
|
export async function getVariant(cfg, audience) {
|
|
109
133
|
const res = await req(cfg, `/api/v1/variants/${encodeURIComponent(audience)}`);
|
|
110
134
|
if (!res.ok)
|
|
111
135
|
await throwServerError(res);
|
|
112
|
-
return res
|
|
136
|
+
return parseJson(res);
|
|
113
137
|
}
|
|
114
138
|
export async function putVariant(cfg, audience, data) {
|
|
115
139
|
const res = await req(cfg, `/api/v1/variants/${encodeURIComponent(audience)}`, {
|
|
@@ -118,7 +142,7 @@ export async function putVariant(cfg, audience, data) {
|
|
|
118
142
|
});
|
|
119
143
|
if (!res.ok)
|
|
120
144
|
await throwServerError(res);
|
|
121
|
-
return res
|
|
145
|
+
return parseJson(res);
|
|
122
146
|
}
|
|
123
147
|
export async function deleteVariant(cfg, audience) {
|
|
124
148
|
const res = await req(cfg, `/api/v1/variants/${encodeURIComponent(audience)}`, {
|
|
@@ -127,3 +151,15 @@ export async function deleteVariant(cfg, audience) {
|
|
|
127
151
|
if (!res.ok)
|
|
128
152
|
await throwServerError(res);
|
|
129
153
|
}
|
|
154
|
+
// Plain-text resume is public (served at /:handle.txt), so no auth is needed —
|
|
155
|
+
// just the handle. A variant key maps onto ?company= (the highest-precedence
|
|
156
|
+
// variant param, which also resolves ?role=/?lang= keys server-side).
|
|
157
|
+
export async function getResumeText(apiBase, handle, variant) {
|
|
158
|
+
const query = variant ? `?company=${encodeURIComponent(variant)}` : "";
|
|
159
|
+
const res = await fetch(`${apiBase}/${handle}.txt${query}`);
|
|
160
|
+
checkEgressBlock({ token: "", apiBase }, res);
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
throw new Error(`Could not fetch text resume (status ${res.status})`);
|
|
163
|
+
}
|
|
164
|
+
return res.text();
|
|
165
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { loadConfig, saveConfig, clearConfig, DEFAULT_API } from "./config.js";
|
|
4
|
-
import { register, whoami, getResume, putResume, patchSection, getSchema, listVariants, getVariant, putVariant, deleteVariant } from "./api.js";
|
|
5
|
-
const
|
|
4
|
+
import { register, whoami, getResume, putResume, patchSection, getSchema, listVariants, getVariant, putVariant, deleteVariant, getResumeText } from "./api.js";
|
|
5
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
6
|
+
const VERSION = pkg.version;
|
|
6
7
|
const VARIANT_FLAG = "--variant=";
|
|
7
8
|
function printVariantReminder(apiBase, handle) {
|
|
8
9
|
if (!handle)
|
|
@@ -21,7 +22,7 @@ function printVariantReminder(apiBase, handle) {
|
|
|
21
22
|
console.log(`See ${apiBase}/llms.txt for the full variant workflow.`);
|
|
22
23
|
}
|
|
23
24
|
const HELP = `
|
|
24
|
-
|
|
25
|
+
cv-pro — your resume as a living site (cv.ha7ch.com)
|
|
25
26
|
|
|
26
27
|
USAGE
|
|
27
28
|
cv-pro <command> [options]
|
|
@@ -33,6 +34,8 @@ COMMANDS
|
|
|
33
34
|
whoami Show authenticated handle
|
|
34
35
|
schema Show resume schema (section names + field shapes)
|
|
35
36
|
get [--variant=<key>] Print current resume (or one variant) as JSON
|
|
37
|
+
export [--txt|--json] Save resume as plain text (default) or JSON
|
|
38
|
+
[--variant=<key>] [-o <file>]
|
|
36
39
|
update [file] Replace entire resume from a JSON file (or stdin)
|
|
37
40
|
update-section <section> Update one section from JSON file (or stdin)
|
|
38
41
|
variants List all stored variants with links
|
|
@@ -52,9 +55,11 @@ EXAMPLES
|
|
|
52
55
|
cv-pro whoami
|
|
53
56
|
cv-pro get
|
|
54
57
|
cv-pro get --variant=openai
|
|
58
|
+
cv-pro export -o resume.txt
|
|
59
|
+
cv-pro export --json --variant=openai -o resume_openai.json
|
|
55
60
|
cv-pro update resume.json
|
|
56
61
|
cv-pro update-section experience experience.json
|
|
57
|
-
echo '{"name":"Lawted"}' | cv update-section header
|
|
62
|
+
echo '{"name":"Lawted"}' | cv-pro update-section header
|
|
58
63
|
cv-pro set-variant openai resume_openai.json
|
|
59
64
|
cv-pro variants
|
|
60
65
|
cv-pro open --variant=openai
|
|
@@ -92,9 +97,9 @@ async function main() {
|
|
|
92
97
|
}
|
|
93
98
|
// login
|
|
94
99
|
if (cmd === "login") {
|
|
95
|
-
const token = args[1];
|
|
100
|
+
const token = args[1]?.trim();
|
|
96
101
|
if (!token) {
|
|
97
|
-
die("Usage: cv login <token>\nGet a token at cv.ha7ch.com");
|
|
102
|
+
die("Usage: cv-pro login <token>\nGet a token at cv.ha7ch.com");
|
|
98
103
|
}
|
|
99
104
|
if (!token.startsWith("cv_pat_")) {
|
|
100
105
|
die("Token must start with cv_pat_");
|
|
@@ -160,24 +165,54 @@ async function main() {
|
|
|
160
165
|
console.log(JSON.stringify(data, null, 2));
|
|
161
166
|
return;
|
|
162
167
|
}
|
|
168
|
+
if (cmd === "export") {
|
|
169
|
+
const rest = args.slice(1);
|
|
170
|
+
const wantJson = rest.includes("--json");
|
|
171
|
+
const variantArg = rest.find((arg) => arg.startsWith(VARIANT_FLAG));
|
|
172
|
+
const variantKey = variantArg?.slice(VARIANT_FLAG.length);
|
|
173
|
+
const outIdx = rest.findIndex((arg) => arg === "-o" || arg === "--out");
|
|
174
|
+
const outFile = outIdx >= 0 ? rest[outIdx + 1] : undefined;
|
|
175
|
+
if (outIdx >= 0 && !outFile) {
|
|
176
|
+
die("Usage: cv-pro export [--txt|--json] [--variant=<key>] [-o <file>]");
|
|
177
|
+
}
|
|
178
|
+
const handle = await resolveHandle(config);
|
|
179
|
+
let content;
|
|
180
|
+
if (wantJson) {
|
|
181
|
+
const data = variantKey ? await getVariant(config, variantKey) : await getResume(config);
|
|
182
|
+
content = JSON.stringify(data, null, 2) + "\n";
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
content = await getResumeText(config.apiBase, handle, variantKey);
|
|
186
|
+
}
|
|
187
|
+
if (outFile) {
|
|
188
|
+
writeFileSync(outFile, content);
|
|
189
|
+
console.log(`Saved ${outFile}`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
process.stdout.write(content);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
163
196
|
if (cmd === "update") {
|
|
164
197
|
const data = readJsonArg(args[1], "resume data");
|
|
165
198
|
process.stdout.write("Updating resume… ");
|
|
166
|
-
await putResume(config, data);
|
|
167
|
-
|
|
168
|
-
|
|
199
|
+
const saved = await putResume(config, data);
|
|
200
|
+
const handle = await resolveHandle(config, saved.username);
|
|
201
|
+
console.log(`✓\nView at ${config.apiBase}/${handle}`);
|
|
202
|
+
printVariantReminder(config.apiBase, handle);
|
|
169
203
|
return;
|
|
170
204
|
}
|
|
171
205
|
if (cmd === "update-section") {
|
|
172
206
|
const section = args[1];
|
|
173
207
|
if (!section)
|
|
174
|
-
die("Usage: cv update-section <section> [file]");
|
|
208
|
+
die("Usage: cv-pro update-section <section> [file]");
|
|
175
209
|
const value = readJsonArg(args[2], `section '${section}'`);
|
|
176
210
|
process.stdout.write(`Updating ${section}… `);
|
|
177
|
-
await patchSection(config, section, value);
|
|
178
|
-
|
|
211
|
+
const saved = await patchSection(config, section, value);
|
|
212
|
+
const handle = await resolveHandle(config, saved.username);
|
|
213
|
+
console.log(`✓\nView at ${config.apiBase}/${handle}`);
|
|
179
214
|
if (section === "experience" || section === "projectsRecent" || section === "projectsDetailed") {
|
|
180
|
-
printVariantReminder(config.apiBase,
|
|
215
|
+
printVariantReminder(config.apiBase, handle);
|
|
181
216
|
}
|
|
182
217
|
return;
|
|
183
218
|
}
|
|
@@ -233,9 +268,10 @@ async function main() {
|
|
|
233
268
|
die("Usage: cv-pro set-variant <key> [file]");
|
|
234
269
|
const data = readJsonArg(args[2], `variant '${key}'`);
|
|
235
270
|
process.stdout.write(`Saving variant '${key}'… `);
|
|
236
|
-
await putVariant(config, key, data);
|
|
271
|
+
const saved = await putVariant(config, key, data);
|
|
272
|
+
const handle = await resolveHandle(config, saved.username);
|
|
237
273
|
console.log(`✓`);
|
|
238
|
-
console.log(`View at ${config.apiBase}/${
|
|
274
|
+
console.log(`View at ${config.apiBase}/${handle}?company=${key}`);
|
|
239
275
|
console.log(`(Also works with ?role=${key} or ?lang=${key})`);
|
|
240
276
|
return;
|
|
241
277
|
}
|
|
@@ -250,6 +286,16 @@ async function main() {
|
|
|
250
286
|
}
|
|
251
287
|
die(`Unknown command: ${cmd}\nRun 'cv-pro help' to see available commands.`);
|
|
252
288
|
}
|
|
289
|
+
async function resolveHandle(cfg, fromResponse) {
|
|
290
|
+
if (cfg.handle)
|
|
291
|
+
return cfg.handle;
|
|
292
|
+
if (fromResponse)
|
|
293
|
+
return fromResponse;
|
|
294
|
+
const result = await whoami(cfg);
|
|
295
|
+
if (result.ok)
|
|
296
|
+
return result.username;
|
|
297
|
+
die("Could not determine handle. Set CV_HANDLE or run 'cv-pro login <token>'.");
|
|
298
|
+
}
|
|
253
299
|
function readJsonArg(filePath, label) {
|
|
254
300
|
if (filePath) {
|
|
255
301
|
try {
|
|
@@ -259,7 +305,11 @@ function readJsonArg(filePath, label) {
|
|
|
259
305
|
die(`Could not read ${label} from file: ${filePath}`);
|
|
260
306
|
}
|
|
261
307
|
}
|
|
262
|
-
// try stdin
|
|
308
|
+
// try stdin, but only if it's actually piped — otherwise readFileSync("/dev/stdin")
|
|
309
|
+
// blocks the terminal forever waiting for input
|
|
310
|
+
if (process.stdin.isTTY) {
|
|
311
|
+
die(`Provide ${label} as a file path or pipe JSON via stdin.`);
|
|
312
|
+
}
|
|
263
313
|
try {
|
|
264
314
|
const stdin = readFileSync("/dev/stdin", "utf8").trim();
|
|
265
315
|
if (stdin)
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ha7ch/cv-pro",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "AI-native resume CLI — update your living resume at cv.ha7ch.com from Claude Code or any terminal",
|
|
5
5
|
"homepage": "https://cv.ha7ch.com",
|
|
6
|
-
"repository": "https://github.com/
|
|
6
|
+
"repository": "https://github.com/HA7CH/cv-pro",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|