@ha7ch/cv-pro 0.5.1
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 +117 -0
- package/dist/api.js +129 -0
- package/dist/config.js +33 -0
- package/dist/index.js +278 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# cv-pro
|
|
2
|
+
|
|
3
|
+
Turn a PDF resume into a living personal site at **cv.ha7ch.com/{handle}**.
|
|
4
|
+
|
|
5
|
+
Drop a PDF, paste text, or describe a change — an AI agent does the rest.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## If you are an AI agent helping a user set up cv-pro
|
|
10
|
+
|
|
11
|
+
Follow these steps exactly, in order. No browser required.
|
|
12
|
+
|
|
13
|
+
**Step 1 — Ask the user one question before doing anything else:**
|
|
14
|
+
|
|
15
|
+
> What handle do you want? Your resume will be live at `cv.ha7ch.com/{handle}`.
|
|
16
|
+
|
|
17
|
+
**Step 2 — Register and log in (one command)**
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx cv-pro@latest register {handle}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This claims the handle and saves the token automatically. Output: `Logged in as @{handle}`.
|
|
24
|
+
|
|
25
|
+
If the handle is taken, the error will say so — ask the user to pick another one and retry.
|
|
26
|
+
|
|
27
|
+
**Step 3 — Publish first resume (optional)**
|
|
28
|
+
|
|
29
|
+
Ask: *Do you have a resume PDF or any content to publish now?*
|
|
30
|
+
|
|
31
|
+
- **PDF** — read the file, extract all fields into JSON, save to `/tmp/resume.json`, then:
|
|
32
|
+
```bash
|
|
33
|
+
npx cv-pro@latest update /tmp/resume.json
|
|
34
|
+
```
|
|
35
|
+
- **Text / paste** — same flow, extract from pasted content.
|
|
36
|
+
- **Later** — skip. The page is already live with an empty template.
|
|
37
|
+
|
|
38
|
+
**Step 4 — Confirm**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx cv-pro@latest whoami
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Tell the user their live URL: **https://cv.ha7ch.com/{handle}**
|
|
45
|
+
|
|
46
|
+
For future edits: drop a PDF or describe changes. Sections: `header`, `personalInfo`, `experience`, `education`, `projectsRecent`, `projectsDetailed`, `skills`, `contact`.
|
|
47
|
+
|
|
48
|
+
Each `experience` and `projectsDetailed` entry takes a `bullets: string[]` field — these render as the role's bullet points on the page. Skipping them leaves the entry as just a header line.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## CLI reference
|
|
53
|
+
|
|
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
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Public JSON URL
|
|
67
|
+
|
|
68
|
+
Every resume is also served as raw JSON at **`https://cv.ha7ch.com/{handle}.json`** — no auth, CORS-open, fetch-friendly for any AI agent.
|
|
69
|
+
|
|
70
|
+
`CV_TOKEN` env var overrides saved credentials.
|
|
71
|
+
|
|
72
|
+
## MCP
|
|
73
|
+
|
|
74
|
+
Replace `cv_pat_...` with your token.
|
|
75
|
+
|
|
76
|
+
**Claude Code**
|
|
77
|
+
```bash
|
|
78
|
+
claude mcp add cv --transport http https://cv.ha7ch.com/api/mcp \
|
|
79
|
+
--header "Authorization: Bearer cv_pat_..."
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Cursor** — `~/.cursor/mcp.json`
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"mcpServers": {
|
|
86
|
+
"cv": {
|
|
87
|
+
"type": "http",
|
|
88
|
+
"url": "https://cv.ha7ch.com/api/mcp",
|
|
89
|
+
"headers": { "Authorization": "Bearer cv_pat_..." }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Codex** — `~/.codex/config.json`
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"cv": {
|
|
100
|
+
"type": "http",
|
|
101
|
+
"url": "https://cv.ha7ch.com/api/mcp",
|
|
102
|
+
"headers": { "Authorization": "Bearer cv_pat_..." }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Tools: `get_resume` · `update_resume` · `update_section`
|
|
109
|
+
|
|
110
|
+
## Troubleshooting
|
|
111
|
+
|
|
112
|
+
**Sandboxed agents fail to connect.** Some hosted AI agents (Claude Code Cloud, ChatGPT Code Interpreter, etc.) run inside an egress allowlist that blocks `cv.ha7ch.com`. The CLI surfaces this as `Sandbox blocked egress to ... (host_not_allowed)`. There is no client-side workaround — run from local Claude Code, or attach the MCP server (`/api/mcp`) as a Custom Connector on claude.ai, which uses a different egress.
|
|
113
|
+
|
|
114
|
+
## Links
|
|
115
|
+
|
|
116
|
+
- Site: [cv.ha7ch.com](https://cv.ha7ch.com)
|
|
117
|
+
- Repo: [github.com/LAWTED/cv-pro](https://github.com/LAWTED/cv-pro)
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
async function throwServerError(res) {
|
|
2
|
+
const body = (await res.json().catch(() => ({})));
|
|
3
|
+
let msg = body.error ?? res.statusText;
|
|
4
|
+
if (body.issues?.length) {
|
|
5
|
+
msg +=
|
|
6
|
+
"\n" +
|
|
7
|
+
body.issues
|
|
8
|
+
.map((i) => ` - ${i.path || "(root)"}: ${i.message}`)
|
|
9
|
+
.join("\n");
|
|
10
|
+
}
|
|
11
|
+
if (body.allowed?.length) {
|
|
12
|
+
msg += `\n Allowed: ${body.allowed.join(", ")}`;
|
|
13
|
+
}
|
|
14
|
+
throw new Error(msg);
|
|
15
|
+
}
|
|
16
|
+
// Some hosted AI agents (Claude Code Cloud, ChatGPT Code Interpreter)
|
|
17
|
+
// run inside sandboxes whose egress proxy denies arbitrary hosts and
|
|
18
|
+
// returns 403 with `x-deny-reason: host_not_allowed` BEFORE the request
|
|
19
|
+
// reaches our server. Surface this clearly instead of letting it look
|
|
20
|
+
// like an auth failure.
|
|
21
|
+
function checkEgressBlock(cfg, res) {
|
|
22
|
+
if (res.status !== 403)
|
|
23
|
+
return;
|
|
24
|
+
const denyReason = res.headers.get("x-deny-reason");
|
|
25
|
+
if (denyReason !== "host_not_allowed")
|
|
26
|
+
return;
|
|
27
|
+
throw new Error(`Sandbox blocked egress to ${cfg.apiBase} (x-deny-reason: host_not_allowed).\n` +
|
|
28
|
+
`This environment cannot reach cv-pro. Workarounds:\n` +
|
|
29
|
+
` • Run from local Claude Code (CLI on your own machine)\n` +
|
|
30
|
+
` • Use claude.ai with a Custom Connector → https://cv.ha7ch.com/api/mcp\n` +
|
|
31
|
+
` • Ask the agent host to allowlist cv.ha7ch.com`);
|
|
32
|
+
}
|
|
33
|
+
async function req(cfg, path, opts = {}) {
|
|
34
|
+
const url = `${cfg.apiBase}${path}`;
|
|
35
|
+
const res = await fetch(url, {
|
|
36
|
+
...opts,
|
|
37
|
+
headers: {
|
|
38
|
+
"Authorization": `Bearer ${cfg.token}`,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
...(opts.headers ?? {}),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
checkEgressBlock(cfg, res);
|
|
44
|
+
return res;
|
|
45
|
+
}
|
|
46
|
+
export async function register(handle, apiBase) {
|
|
47
|
+
const url = `${apiBase}/api/register`;
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ handle }),
|
|
52
|
+
});
|
|
53
|
+
checkEgressBlock({ token: "", apiBase }, res);
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error(data.error ?? res.statusText);
|
|
57
|
+
return { handle: data.handle, token: data.token };
|
|
58
|
+
}
|
|
59
|
+
export async function whoami(cfg) {
|
|
60
|
+
const res = await req(cfg, "/api/v1/resume");
|
|
61
|
+
if (res.status === 404)
|
|
62
|
+
return { ok: false, reason: "no-resume" };
|
|
63
|
+
if (res.status === 401 || res.status === 403) {
|
|
64
|
+
return { ok: false, reason: "unauthorized" };
|
|
65
|
+
}
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return { ok: false, reason: "error" };
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
return { ok: true, username: data.username };
|
|
70
|
+
}
|
|
71
|
+
export async function getResume(cfg) {
|
|
72
|
+
const res = await req(cfg, "/api/v1/resume");
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
await throwServerError(res);
|
|
75
|
+
return res.json();
|
|
76
|
+
}
|
|
77
|
+
export async function putResume(cfg, data) {
|
|
78
|
+
const res = await req(cfg, "/api/v1/resume", {
|
|
79
|
+
method: "PUT",
|
|
80
|
+
body: JSON.stringify(data),
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
await throwServerError(res);
|
|
84
|
+
return res.json();
|
|
85
|
+
}
|
|
86
|
+
export async function patchSection(cfg, section, value) {
|
|
87
|
+
const res = await req(cfg, "/api/v1/resume", {
|
|
88
|
+
method: "PATCH",
|
|
89
|
+
body: JSON.stringify({ section, value }),
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
await throwServerError(res);
|
|
93
|
+
return res.json();
|
|
94
|
+
}
|
|
95
|
+
export async function getSchema(apiBase) {
|
|
96
|
+
const res = await fetch(`${apiBase}/api/v1/schema`);
|
|
97
|
+
checkEgressBlock({ token: "", apiBase }, res);
|
|
98
|
+
if (!res.ok)
|
|
99
|
+
await throwServerError(res);
|
|
100
|
+
return res.json();
|
|
101
|
+
}
|
|
102
|
+
export async function listVariants(cfg) {
|
|
103
|
+
const res = await req(cfg, "/api/v1/variants");
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
await throwServerError(res);
|
|
106
|
+
return res.json();
|
|
107
|
+
}
|
|
108
|
+
export async function getVariant(cfg, audience) {
|
|
109
|
+
const res = await req(cfg, `/api/v1/variants/${encodeURIComponent(audience)}`);
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
await throwServerError(res);
|
|
112
|
+
return res.json();
|
|
113
|
+
}
|
|
114
|
+
export async function putVariant(cfg, audience, data) {
|
|
115
|
+
const res = await req(cfg, `/api/v1/variants/${encodeURIComponent(audience)}`, {
|
|
116
|
+
method: "PUT",
|
|
117
|
+
body: JSON.stringify(data),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok)
|
|
120
|
+
await throwServerError(res);
|
|
121
|
+
return res.json();
|
|
122
|
+
}
|
|
123
|
+
export async function deleteVariant(cfg, audience) {
|
|
124
|
+
const res = await req(cfg, `/api/v1/variants/${encodeURIComponent(audience)}`, {
|
|
125
|
+
method: "DELETE",
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
await throwServerError(res);
|
|
129
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".cv");
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
6
|
+
export const DEFAULT_API = "https://cv.ha7ch.com";
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
const envToken = process.env.CV_TOKEN;
|
|
9
|
+
if (envToken) {
|
|
10
|
+
return {
|
|
11
|
+
token: envToken,
|
|
12
|
+
handle: process.env.CV_HANDLE,
|
|
13
|
+
apiBase: process.env.CV_API ?? DEFAULT_API,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (!existsSync(CONFIG_FILE))
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function saveConfig(config) {
|
|
26
|
+
if (!existsSync(CONFIG_DIR))
|
|
27
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
28
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
|
|
29
|
+
}
|
|
30
|
+
export function clearConfig() {
|
|
31
|
+
if (existsSync(CONFIG_FILE))
|
|
32
|
+
writeFileSync(CONFIG_FILE, "{}", "utf8");
|
|
33
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
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 VERSION = "0.5.0";
|
|
6
|
+
const VARIANT_FLAG = "--variant=";
|
|
7
|
+
function printVariantReminder(apiBase, handle) {
|
|
8
|
+
if (!handle)
|
|
9
|
+
return;
|
|
10
|
+
console.log("");
|
|
11
|
+
console.log("Tip — create tailored variants for each audience:");
|
|
12
|
+
console.log("");
|
|
13
|
+
console.log(" • Company variant → share via");
|
|
14
|
+
console.log(` ${apiBase}/${handle}?company=openai`);
|
|
15
|
+
console.log(" • Role variant → share via");
|
|
16
|
+
console.log(` ${apiBase}/${handle}?role=designer`);
|
|
17
|
+
console.log(" • Language variant → share via");
|
|
18
|
+
console.log(` ${apiBase}/${handle}?lang=en`);
|
|
19
|
+
console.log("");
|
|
20
|
+
console.log(`Use 'cv-pro set-variant <key> [file]' to store a tailored version.`);
|
|
21
|
+
console.log(`See ${apiBase}/llms.txt for the full variant workflow.`);
|
|
22
|
+
}
|
|
23
|
+
const HELP = `
|
|
24
|
+
aicv — AI-native resume CLI (cv.ha7ch.com)
|
|
25
|
+
|
|
26
|
+
USAGE
|
|
27
|
+
cv-pro <command> [options]
|
|
28
|
+
|
|
29
|
+
COMMANDS
|
|
30
|
+
register <handle> Claim a handle and get a token (no browser needed)
|
|
31
|
+
login <token> Save an existing personal access token
|
|
32
|
+
logout Remove saved credentials
|
|
33
|
+
whoami Show authenticated handle
|
|
34
|
+
schema Show resume schema (section names + field shapes)
|
|
35
|
+
get [--variant=<key>] Print current resume (or one variant) as JSON
|
|
36
|
+
update [file] Replace entire resume from a JSON file (or stdin)
|
|
37
|
+
update-section <section> Update one section from JSON file (or stdin)
|
|
38
|
+
variants List all stored variants with links
|
|
39
|
+
get-variant <key> Print variant JSON
|
|
40
|
+
set-variant <key> [file] Create/update a variant from file or stdin
|
|
41
|
+
delete-variant <key> Delete a variant
|
|
42
|
+
open [--json] [--variant=<key>] Open resume page in browser
|
|
43
|
+
|
|
44
|
+
SECTIONS
|
|
45
|
+
header, personalInfo, experience, education,
|
|
46
|
+
projectsRecent, projectsDetailed, skills, contact
|
|
47
|
+
|
|
48
|
+
Run 'cv-pro schema' for the full field-level structure.
|
|
49
|
+
|
|
50
|
+
EXAMPLES
|
|
51
|
+
cv-pro register lawted
|
|
52
|
+
cv-pro whoami
|
|
53
|
+
cv-pro get
|
|
54
|
+
cv-pro get --variant=openai
|
|
55
|
+
cv-pro update resume.json
|
|
56
|
+
cv-pro update-section experience experience.json
|
|
57
|
+
echo '{"name":"Lawted"}' | cv update-section header
|
|
58
|
+
cv-pro set-variant openai resume_openai.json
|
|
59
|
+
cv-pro variants
|
|
60
|
+
cv-pro open --variant=openai
|
|
61
|
+
|
|
62
|
+
ENV
|
|
63
|
+
CV_TOKEN token (overrides saved config)
|
|
64
|
+
CV_HANDLE handle (required when using CV_TOKEN)
|
|
65
|
+
CV_API API base URL (default: https://cv.ha7ch.com)
|
|
66
|
+
`.trim();
|
|
67
|
+
async function main() {
|
|
68
|
+
const args = process.argv.slice(2);
|
|
69
|
+
const cmd = args[0];
|
|
70
|
+
if (!cmd || cmd === "--help" || cmd === "help" || cmd === "-h") {
|
|
71
|
+
console.log(HELP);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
75
|
+
console.log(VERSION);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// register
|
|
79
|
+
if (cmd === "register") {
|
|
80
|
+
const handle = args[1]?.toLowerCase().trim();
|
|
81
|
+
if (!handle)
|
|
82
|
+
die("Usage: cv-pro register <handle>");
|
|
83
|
+
process.stdout.write(`Registering @${handle}… `);
|
|
84
|
+
const apiBase = process.env.CV_API ?? DEFAULT_API;
|
|
85
|
+
const result = await register(handle, apiBase);
|
|
86
|
+
console.log("✓");
|
|
87
|
+
saveConfig({ token: result.token, handle: result.handle, apiBase });
|
|
88
|
+
console.log(`Logged in as @${result.handle}`);
|
|
89
|
+
console.log(`Page: ${apiBase}/${result.handle}`);
|
|
90
|
+
console.log(`JSON: ${apiBase}/${result.handle}.json (public, AI-readable)`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// login
|
|
94
|
+
if (cmd === "login") {
|
|
95
|
+
const token = args[1];
|
|
96
|
+
if (!token) {
|
|
97
|
+
die("Usage: cv login <token>\nGet a token at cv.ha7ch.com");
|
|
98
|
+
}
|
|
99
|
+
if (!token.startsWith("cv_pat_")) {
|
|
100
|
+
die("Token must start with cv_pat_");
|
|
101
|
+
}
|
|
102
|
+
// verify token by calling whoami
|
|
103
|
+
const cfg = { token, apiBase: DEFAULT_API };
|
|
104
|
+
process.stdout.write("Verifying token… ");
|
|
105
|
+
const result = await whoami(cfg);
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
if (result.reason === "no-resume") {
|
|
108
|
+
die("\nToken valid, but no resume on file. Run: cv-pro register <handle>");
|
|
109
|
+
}
|
|
110
|
+
if (result.reason === "unauthorized")
|
|
111
|
+
die("\nInvalid token.");
|
|
112
|
+
die("\nServer error. Try again.");
|
|
113
|
+
}
|
|
114
|
+
console.log(`✓\nLogged in as @${result.username}`);
|
|
115
|
+
saveConfig({ token, handle: result.username, apiBase: DEFAULT_API });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// logout
|
|
119
|
+
if (cmd === "logout") {
|
|
120
|
+
clearConfig();
|
|
121
|
+
console.log("Logged out.");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// schema — public, no auth needed
|
|
125
|
+
if (cmd === "schema") {
|
|
126
|
+
const apiBase = loadConfig()?.apiBase ?? process.env.CV_API ?? DEFAULT_API;
|
|
127
|
+
const wantJson = args.slice(1).includes("--json");
|
|
128
|
+
const result = await getSchema(apiBase);
|
|
129
|
+
console.log(wantJson ? JSON.stringify(result.json, null, 2) : result.text);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// commands that need auth
|
|
133
|
+
const config = loadConfig();
|
|
134
|
+
if (!config?.token) {
|
|
135
|
+
die("Not logged in. Run: cv-pro login <token>\nGet a token at cv.ha7ch.com");
|
|
136
|
+
}
|
|
137
|
+
if (cmd === "whoami") {
|
|
138
|
+
let handle = config.handle;
|
|
139
|
+
if (!handle) {
|
|
140
|
+
const result = await whoami(config);
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
if (result.reason === "unauthorized")
|
|
143
|
+
die("Invalid token.");
|
|
144
|
+
if (result.reason === "no-resume") {
|
|
145
|
+
die("Token valid, but no resume on file. Run: cv-pro register <handle>");
|
|
146
|
+
}
|
|
147
|
+
die("Server unreachable.");
|
|
148
|
+
}
|
|
149
|
+
handle = result.username;
|
|
150
|
+
}
|
|
151
|
+
console.log(`@${handle}`);
|
|
152
|
+
console.log(`Page: ${config.apiBase}/${handle}`);
|
|
153
|
+
console.log(`JSON: ${config.apiBase}/${handle}.json (public, AI-readable)`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (cmd === "get") {
|
|
157
|
+
const variantArg = args.find((arg) => arg.startsWith(VARIANT_FLAG));
|
|
158
|
+
const variantKey = variantArg?.slice(VARIANT_FLAG.length);
|
|
159
|
+
const data = variantKey ? await getVariant(config, variantKey) : await getResume(config);
|
|
160
|
+
console.log(JSON.stringify(data, null, 2));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (cmd === "update") {
|
|
164
|
+
const data = readJsonArg(args[1], "resume data");
|
|
165
|
+
process.stdout.write("Updating resume… ");
|
|
166
|
+
await putResume(config, data);
|
|
167
|
+
console.log(`✓\nView at ${config.apiBase}/${config.handle}`);
|
|
168
|
+
printVariantReminder(config.apiBase, config.handle);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (cmd === "update-section") {
|
|
172
|
+
const section = args[1];
|
|
173
|
+
if (!section)
|
|
174
|
+
die("Usage: cv update-section <section> [file]");
|
|
175
|
+
const value = readJsonArg(args[2], `section '${section}'`);
|
|
176
|
+
process.stdout.write(`Updating ${section}… `);
|
|
177
|
+
await patchSection(config, section, value);
|
|
178
|
+
console.log(`✓\nView at ${config.apiBase}/${config.handle}`);
|
|
179
|
+
if (section === "experience" || section === "projectsRecent" || section === "projectsDetailed") {
|
|
180
|
+
printVariantReminder(config.apiBase, config.handle);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (cmd === "open") {
|
|
185
|
+
const wantJson = args.slice(1).includes("--json");
|
|
186
|
+
const variantArg = args.slice(1).find((arg) => arg.startsWith(VARIANT_FLAG));
|
|
187
|
+
const variantKey = variantArg?.slice(VARIANT_FLAG.length);
|
|
188
|
+
let url;
|
|
189
|
+
if (variantKey) {
|
|
190
|
+
url = `${config.apiBase}/${config.handle}?company=${variantKey}`;
|
|
191
|
+
}
|
|
192
|
+
else if (wantJson) {
|
|
193
|
+
url = `${config.apiBase}/${config.handle}.json`;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
url = `${config.apiBase}/${config.handle}`;
|
|
197
|
+
}
|
|
198
|
+
const { execSync } = await import("node:child_process");
|
|
199
|
+
const opener = process.platform === "darwin" ? "open" :
|
|
200
|
+
process.platform === "win32" ? "start" : "xdg-open";
|
|
201
|
+
execSync(`${opener} "${url}"`);
|
|
202
|
+
console.log(url);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (cmd === "variants") {
|
|
206
|
+
const variants = await listVariants(config);
|
|
207
|
+
if (variants.length === 0) {
|
|
208
|
+
console.log("No variants yet. Use 'cv-pro set-variant <key> [file]' to create one.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
console.log("Stored variants:\n");
|
|
212
|
+
for (const { audience, updatedAt } of variants) {
|
|
213
|
+
const date = updatedAt.slice(0, 10);
|
|
214
|
+
console.log(` ${audience.padEnd(16)} updated ${date}`);
|
|
215
|
+
console.log(` ?company=${audience} → ${config.apiBase}/${config.handle}?company=${audience}`);
|
|
216
|
+
console.log(` ?role=${audience} → ${config.apiBase}/${config.handle}?role=${audience}`);
|
|
217
|
+
console.log(` ?lang=${audience} → ${config.apiBase}/${config.handle}?lang=${audience}`);
|
|
218
|
+
console.log("");
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (cmd === "get-variant") {
|
|
223
|
+
const key = args[1];
|
|
224
|
+
if (!key)
|
|
225
|
+
die("Usage: cv-pro get-variant <key>");
|
|
226
|
+
const data = await getVariant(config, key);
|
|
227
|
+
console.log(JSON.stringify(data, null, 2));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (cmd === "set-variant") {
|
|
231
|
+
const key = args[1];
|
|
232
|
+
if (!key)
|
|
233
|
+
die("Usage: cv-pro set-variant <key> [file]");
|
|
234
|
+
const data = readJsonArg(args[2], `variant '${key}'`);
|
|
235
|
+
process.stdout.write(`Saving variant '${key}'… `);
|
|
236
|
+
await putVariant(config, key, data);
|
|
237
|
+
console.log(`✓`);
|
|
238
|
+
console.log(`View at ${config.apiBase}/${config.handle}?company=${key}`);
|
|
239
|
+
console.log(`(Also works with ?role=${key} or ?lang=${key})`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (cmd === "delete-variant") {
|
|
243
|
+
const key = args[1];
|
|
244
|
+
if (!key)
|
|
245
|
+
die("Usage: cv-pro delete-variant <key>");
|
|
246
|
+
process.stdout.write(`Deleting variant '${key}'… `);
|
|
247
|
+
await deleteVariant(config, key);
|
|
248
|
+
console.log("✓");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
die(`Unknown command: ${cmd}\nRun 'cv-pro help' to see available commands.`);
|
|
252
|
+
}
|
|
253
|
+
function readJsonArg(filePath, label) {
|
|
254
|
+
if (filePath) {
|
|
255
|
+
try {
|
|
256
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
die(`Could not read ${label} from file: ${filePath}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// try stdin
|
|
263
|
+
try {
|
|
264
|
+
const stdin = readFileSync("/dev/stdin", "utf8").trim();
|
|
265
|
+
if (stdin)
|
|
266
|
+
return JSON.parse(stdin);
|
|
267
|
+
}
|
|
268
|
+
catch { }
|
|
269
|
+
die(`Provide ${label} as a file path or pipe JSON via stdin.`);
|
|
270
|
+
}
|
|
271
|
+
function die(msg) {
|
|
272
|
+
console.error(`Error: ${msg}`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
main().catch((err) => {
|
|
276
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ha7ch/cv-pro",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "AI-native resume CLI — update your living resume at cv.ha7ch.com from Claude Code or any terminal",
|
|
5
|
+
"homepage": "https://cv.ha7ch.com",
|
|
6
|
+
"repository": "https://github.com/LAWTED/cv-pro",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"cv-pro": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"registry": "https://registry.npmjs.org/"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"dev": "tsx src/index.ts",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^20",
|
|
26
|
+
"typescript": "^5",
|
|
27
|
+
"tsx": "^4"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
}
|
|
32
|
+
}
|