zephinax 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +20 -10
  2. package/bin/index.js +368 -65
  3. package/package.json +15 -3
package/README.md CHANGED
@@ -1,18 +1,28 @@
1
1
  <div align="center">
2
- <h2>Zephinax's Stats</h2>
2
+ <img src="./logo.svg" alt="Zephinax Logo" width="100" />
3
+ <h1>Amirali Rajabi (Zephinax)</h1>
4
+ <p><em>Interdisciplinary engineer turning ideas into functional products.</em></p>
5
+
6
+ <p>
7
+ <a href="https://www.npmjs.com/package/zephinax"><img src="https://img.shields.io/npm/v/zephinax?color=006aff&label=CLI%20Version" alt="npm version" /></a>
8
+ <a href="https://zephinax.com"><img src="https://img.shields.io/badge/Website-zephinax.com-006aff" alt="Website" /></a>
9
+ <a href="mailto:zephinax@gmail.com"><img src="https://img.shields.io/badge/Email-zephinax%40gmail.com-006aff" alt="Email" /></a>
10
+ </p>
11
+
12
+ <pre><code>npx zephinax</code></pre>
13
+ <p><sub>Interactive by default in terminal. Use <code>--json</code> for machine-readable output or <code>--static</code> for non-interactive.</sub></p>
3
14
  </div>
4
15
 
5
16
  <br>
6
17
 
18
+ <div align="center">
19
+ <h2>Activity & Stats</h2>
20
+ </div>
21
+
7
22
  <p align="center">
8
- <img alt="Mark streak" src="https://streak-stats.demolab.com/?user=Zephinax&hide_border=true&theme=transparent"/>
23
+ <img alt="Streak Stats" src="https://streak-stats.demolab.com/?user=Zephinax&hide_border=true&theme=transparent"/>
9
24
  </p>
10
25
 
11
- <!-- <div align="center">
12
- <img align="center" src="http://github-profile-summary-cards.vercel.app/api/cards/stats?username=Zephinax&theme=transparent" height="180em" alt="Stats"/>
13
- <img align="center" src="http://github-profile-summary-cards.vercel.app/api/cards/productive-time?username=Zephinax&theme=transparent&utcOffset=3.5" height="180em" alt="Time Graph"/>
14
- <img align="center" src="http://github-profile-summary-cards.vercel.app/api/cards/profile-details?username=Zephinax&theme=transparent" height="180em" alt="Commitments"/>
15
- </div> -->
16
-
17
- <img src="./profile-3d-contrib/profile-3d-contrib.svg" alt="profile" >
18
-
26
+ <p align="center">
27
+ <img src="./profile-3d-contrib/profile-3d-contrib.svg" alt="3D Profile Contributions" width="100%">
28
+ </p>
package/bin/index.js CHANGED
@@ -2,57 +2,96 @@
2
2
  "use strict";
3
3
 
4
4
  const https = require("https");
5
+ const { exec, spawnSync } = require("child_process");
6
+ const readline = require("readline");
5
7
 
8
+ const PKG = require("../package.json");
6
9
  const API_URL = process.env.ZEPHINAX_API_URL || "https://zephinax.com/api/user";
10
+ const CONTENT_MARGIN = 1;
11
+
12
+ const DEFAULT_SKILLS = {
13
+ Stack: "Next.js • React • Node.js • Express • MongoDB",
14
+ Systems: "Embedded C/C++ • Firmware • Mechatronics",
15
+ DevOps: "Docker • Docker Compose • Git • Linux"
16
+ };
17
+
18
+ const FALLBACK_PROFILE = {
19
+ firstName: "Amirali",
20
+ lastName: "Rajabi",
21
+ displayName: "Zephinax",
22
+ username: "zephinax",
23
+ gender: "male",
24
+ pronouns: "he/him",
25
+ bio: "Interdisciplinary engineer turning ideas into functional products.",
26
+ address: "Tehran, Iran",
27
+ email: "zephinax@gmail.com",
28
+ website: "https://zephinax.com",
29
+ jobTitle: "Systems Engineer",
30
+ about: `I'm Amirali, an interdisciplinary engineer with a background in mechatronics and practical experience across software development, hardware engineering, product development, and system integration.
31
+ My primary focus is turning ideas into functional products across the entire lifecycle — from system architecture, PCB design, and embedded firmware to full-stack web applications and cloud deployments.
32
+ I specialize in building complete systems: combining microcontrollers and embedded devices (ESP32, Arduino, Raspberry Pi) with modern web platforms (Next.js, React, Express, MongoDB, Docker). I enjoy designing scalable architectures, bridging physical hardware with web services, and solving real-world operational challenges.
33
+ Currently completing my studies in Industrial Mechatronics while working as a Systems Engineer, continually refining practical engineering and technical leadership skills.`,
34
+ quote: "Zephinax represents the endless pursuit of innovation in the world of technology.",
35
+ skills: DEFAULT_SKILLS,
36
+ timeZone: "Asia/Tehran"
37
+ };
7
38
 
8
39
  const color = {
9
40
  cyan: (str) => `\x1b[36m${str}\x1b[0m`,
10
41
  magenta: (str) => `\x1b[35m${str}\x1b[0m`,
11
42
  yellow: (str) => `\x1b[33m${str}\x1b[0m`,
43
+ green: (str) => `\x1b[32m${str}\x1b[0m`,
44
+ blue: (str) => `\x1b[34m${str}\x1b[0m`,
12
45
  bold: (str) => `\x1b[1m${str}\x1b[0m`,
13
- dim: (str) => `\x1b[2m${str}\x1b[0m`
46
+ dim: (str) => `\x1b[2m${str}\x1b[0m`,
47
+ italic: (str) => `\x1b[3m${str}\x1b[0m`
14
48
  };
15
49
 
16
- const CONTENT_MARGIN = 1;
50
+ function hyperlink(text, url) {
51
+ if (!url) return text;
52
+ return `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007`;
53
+ }
17
54
 
18
- async function fetchProfile(url) {
19
- if (typeof fetch === "function") {
20
- const res = await fetch(url);
21
- if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
22
- return res.json();
23
- }
55
+ function stripAnsi(str = "") {
56
+ return str
57
+ .replace(/\u001b\]8;;[^\u0007\x1b]*(\u0007|\u001b\\)/g, "")
58
+ .replace(/\u001b\[[0-9;]*[a-zA-Z]/g, "")
59
+ .replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
60
+ }
24
61
 
25
- // Fallback for environments without global fetch.
26
- return new Promise((resolve, reject) => {
27
- https
28
- .get(url, (res) => {
29
- let data = "";
30
- res.on("data", (chunk) => (data += chunk));
31
- res.on("end", () => {
32
- if (res.statusCode < 200 || res.statusCode >= 300) {
33
- return reject(new Error(`Request failed with status ${res.statusCode}`));
34
- }
35
- try {
36
- resolve(JSON.parse(data));
37
- } catch (err) {
38
- reject(err);
39
- }
40
- });
41
- })
42
- .on("error", reject);
43
- });
62
+ function visibleLength(str = "") {
63
+ return stripAnsi(str).length;
44
64
  }
45
65
 
46
- function clean(text) {
47
- if (!text) return "";
48
- return text
49
- .replace(/\*\*/g, "") // remove bold markers
50
- .replace(/^>\s?/gm, "") // strip block quotes
51
- .replace(/\r?\n\s*/g, "\n") // normalize whitespace
52
- .trim();
66
+ function parseAboutAndQuote(text) {
67
+ if (!text) return { about: "", quote: "" };
68
+ const lines = text.split(/\r?\n/);
69
+ const quoteLines = [];
70
+ const regularLines = [];
71
+
72
+ for (const line of lines) {
73
+ const trimmed = line.trim();
74
+ if (trimmed.startsWith(">")) {
75
+ quoteLines.push(trimmed.replace(/^>\s*/, ""));
76
+ } else {
77
+ regularLines.push(line);
78
+ }
79
+ }
80
+
81
+ const cleanText = (str) =>
82
+ str
83
+ .replace(/\*\*(.*?)\*\*/g, "$1")
84
+ .replace(/^\s*also a\b/im, "Also a")
85
+ .replace(/\r?\n\s*/g, "\n")
86
+ .trim();
87
+
88
+ return {
89
+ about: cleanText(regularLines.join("\n")),
90
+ quote: cleanText(quoteLines.join(" "))
91
+ };
53
92
  }
54
93
 
55
- function wrapText(text, width, indent) {
94
+ function wrapText(text, width, indent = 0) {
56
95
  if (!text) return [];
57
96
  const indentStr = " ".repeat(indent);
58
97
  return text
@@ -66,7 +105,7 @@ function wrapText(text, width, indent) {
66
105
 
67
106
  words.forEach((word) => {
68
107
  const candidate = current ? `${current} ${word}` : word;
69
- if (candidate.length > width) {
108
+ if (visibleLength(candidate) > width) {
70
109
  if (current) lines.push(current);
71
110
  current = word;
72
111
  } else {
@@ -79,19 +118,7 @@ function wrapText(text, width, indent) {
79
118
  }
80
119
 
81
120
  function divider(char = "-", length = 60) {
82
- return char.repeat(length);
83
- }
84
-
85
- function stripAnsi(str = "") {
86
- return str.replace(
87
- // eslint-disable-next-line no-control-regex
88
- /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
89
- ""
90
- );
91
- }
92
-
93
- function visibleLength(str = "") {
94
- return stripAnsi(str).length;
121
+ return char.repeat(Math.max(0, length));
95
122
  }
96
123
 
97
124
  function boxTop(label, width) {
@@ -121,54 +148,330 @@ function boxDivider(label, width) {
121
148
  return `├${tag}${divider("─", lineLen)}┤`;
122
149
  }
123
150
 
124
- function fieldLine(label, value, width) {
151
+ function fieldLine(label, value, width, linkUrl = null) {
125
152
  if (!value) return null;
126
- const labelText = `${label}:`.padEnd(8);
127
- return boxLine(`${color.yellow(labelText)} ${value}`, width);
153
+ const labelText = `${label}:`.padEnd(10);
154
+ const formattedVal = linkUrl ? hyperlink(color.cyan(value), linkUrl) : value;
155
+ return boxLine(`${color.yellow(labelText)} ${formattedVal}`, width);
128
156
  }
129
157
 
130
- function render(profile) {
131
- const about = clean(profile.about || profile.bio);
158
+ function render(profile, widthOverride) {
159
+ const { about, quote } = parseAboutAndQuote(profile.about);
160
+ const finalQuote = profile.quote || quote;
161
+ const skills = profile.skills || DEFAULT_SKILLS;
162
+
132
163
  const header =
133
- `${color.bold(profile.displayName || profile.username || "Zephinax")} ${profile.pronouns ? `(${profile.pronouns})` : ""}`.trim();
164
+ `${color.bold(profile.displayName || profile.username || "Zephinax")} ${profile.pronouns ? color.dim(`(${profile.pronouns})`) : ""}`.trim();
134
165
  const fullName =
135
166
  profile.firstName || profile.lastName
136
167
  ? `${profile.firstName || ""} ${profile.lastName || ""}`.trim()
137
168
  : null;
138
169
 
139
- const width = Math.max(60, Math.min(process.stdout.columns || 80, 100));
170
+ const terminalCols = process.stdout.columns || 80;
171
+ const width = widthOverride || Math.max(68, Math.min(terminalCols, 96));
140
172
  const contentWidth = Math.max(0, width - 4 - CONTENT_MARGIN * 2);
141
173
 
142
174
  const primary = color.cyan(header);
143
175
  const secondary = fullName ? color.dim(fullName) : null;
144
- const aboutLines = about ? wrapText(about, contentWidth - 2, 0) : [];
176
+ const tagline = profile.bio ? color.italic(`"${profile.bio}"`) : null;
177
+ const aboutLines = about ? wrapText(about, contentWidth, 0) : [];
178
+ const quoteLines = finalQuote ? wrapText(`"${finalQuote}"`, contentWidth, 0) : [];
179
+
180
+ const githubUrl = `https://github.com/${profile.username || "zephinax"}`;
145
181
 
146
182
  const lines = [
147
183
  boxTop("Profile", width),
148
184
  boxLine(primary, width),
149
185
  secondary ? boxLine(secondary, width) : null,
186
+ tagline ? boxLine(tagline, width) : null,
150
187
  boxDivider("details", width),
151
188
  fieldLine("Role", profile.jobTitle, width),
189
+ fieldLine("Location", profile.address, width),
152
190
  fieldLine("Timezone", profile.timeZone, width),
153
191
  boxDivider("contact", width),
154
- fieldLine("Email", profile.email, width),
155
- fieldLine("Web", profile.website, width),
192
+ fieldLine("Web", profile.website, width, profile.website),
193
+ fieldLine("GitHub", githubUrl, width, githubUrl),
194
+ fieldLine("Email", profile.email, width, profile.email ? `mailto:${profile.email}` : null),
195
+ boxDivider("skills", width),
196
+ ...Object.entries(skills).map(([key, val]) => fieldLine(key, val, width)),
156
197
  aboutLines.length ? boxDivider("about", width) : null,
157
198
  ...aboutLines.map((l) => boxLine(l, width)),
199
+ quoteLines.length ? boxDivider("philosophy", width) : null,
200
+ ...quoteLines.map((l) => boxLine(color.italic(l), width)),
158
201
  boxBottom(width)
159
202
  ].filter(Boolean);
160
203
 
161
204
  return lines.join("\n");
162
205
  }
163
206
 
164
- async function main() {
207
+ function renderPlain(profile) {
208
+ const { about, quote } = parseAboutAndQuote(profile.about);
209
+ const finalQuote = profile.quote || quote;
210
+ const fullName = [profile.firstName, profile.lastName].filter(Boolean).join(" ");
211
+ const skills = profile.skills || DEFAULT_SKILLS;
212
+ const githubUrl = `https://github.com/${profile.username || "zephinax"}`;
213
+
214
+ const lines = [
215
+ `${profile.displayName || profile.username || "Zephinax"}${profile.pronouns ? ` (${profile.pronouns})` : ""}`,
216
+ fullName || null,
217
+ profile.bio ? `"${profile.bio}"` : null,
218
+ "",
219
+ "--- DETAILS ---",
220
+ profile.jobTitle ? `Role: ${profile.jobTitle}` : null,
221
+ profile.address ? `Location: ${profile.address}` : null,
222
+ profile.timeZone ? `Timezone: ${profile.timeZone}` : null,
223
+ "",
224
+ "--- CONTACT ---",
225
+ profile.website ? `Web: ${profile.website}` : null,
226
+ `GitHub: ${githubUrl}`,
227
+ profile.email ? `Email: ${profile.email}` : null,
228
+ "",
229
+ "--- SKILLS ---",
230
+ ...Object.entries(skills).map(([key, val]) => `${(key + ":").padEnd(10)} ${val}`),
231
+ "",
232
+ about ? "--- ABOUT ---" : null,
233
+ about || null,
234
+ "",
235
+ finalQuote ? "--- PHILOSOPHY ---" : null,
236
+ finalQuote ? `"${finalQuote}"` : null
237
+ ].filter((l) => l !== null);
238
+
239
+ return lines.join("\n");
240
+ }
241
+
242
+ function openUrl(url) {
243
+ const platform = process.platform;
244
+ let cmd;
245
+ if (platform === "darwin") {
246
+ cmd = `open "${url}"`;
247
+ } else if (platform === "win32") {
248
+ cmd = `start "" "${url}"`;
249
+ } else {
250
+ cmd = `xdg-open "${url}"`;
251
+ }
252
+ exec(cmd, () => {});
253
+ }
254
+
255
+ function copyToClipboard(text) {
256
+ const platform = process.platform;
165
257
  try {
166
- const data = await fetchProfile(API_URL);
167
- if (!data || !data.data) throw new Error("No profile data returned.");
168
- console.log(render(data.data));
258
+ if (platform === "darwin") {
259
+ spawnSync("pbcopy", { input: text });
260
+ return true;
261
+ } else if (platform === "win32") {
262
+ spawnSync("clip", { input: text });
263
+ return true;
264
+ } else {
265
+ const resXclip = spawnSync("which", ["xclip"]);
266
+ if (resXclip.status === 0) {
267
+ spawnSync("xclip", ["-selection", "clipboard"], { input: text });
268
+ return true;
269
+ }
270
+ const resWl = spawnSync("which", ["wl-copy"]);
271
+ if (resWl.status === 0) {
272
+ spawnSync("wl-copy", { input: text });
273
+ return true;
274
+ }
275
+ return false;
276
+ }
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ function runInteractive(profile) {
283
+ return new Promise((resolve) => {
284
+ const email = profile.email || "zephinax@gmail.com";
285
+ const web = profile.website || "https://zephinax.com";
286
+ const github = `https://github.com/${profile.username || "zephinax"}`;
287
+
288
+ const rl = readline.createInterface({
289
+ input: process.stdin,
290
+ output: process.stdout
291
+ });
292
+
293
+ console.log("\n" + color.bold(color.cyan("Actions:")));
294
+ console.log(` ${color.yellow("[1]")} 🌐 Open Website ${color.dim(`(${web})`)}`);
295
+ console.log(` ${color.yellow("[2]")} 🐙 Open GitHub ${color.dim(`(${github})`)}`);
296
+ console.log(` ${color.yellow("[3]")} ✉️ Send Email ${color.dim(`(${email})`)}`);
297
+ console.log(` ${color.yellow("[4]")} 📋 Copy Email ${color.dim(`(${email})`)}`);
298
+ console.log(` ${color.yellow("[q]")} 🚪 Exit\n`);
299
+
300
+ const ask = () => {
301
+ rl.question(color.cyan("Select an option (1-4, or press Enter/q to exit): "), (answer) => {
302
+ const choice = answer.trim().toLowerCase();
303
+ switch (choice) {
304
+ case "1":
305
+ console.log(color.dim(`Opening ${web}...`));
306
+ openUrl(web);
307
+ ask();
308
+ break;
309
+ case "2":
310
+ console.log(color.dim(`Opening ${github}...`));
311
+ openUrl(github);
312
+ ask();
313
+ break;
314
+ case "3":
315
+ console.log(color.dim(`Opening email client...`));
316
+ openUrl(`mailto:${email}`);
317
+ ask();
318
+ break;
319
+ case "4": {
320
+ const ok = copyToClipboard(email);
321
+ if (ok) {
322
+ console.log(color.green(`✓ Email copied to clipboard!`));
323
+ } else {
324
+ console.log(color.yellow(`Clipboard unavailable. Email: ${email}`));
325
+ }
326
+ ask();
327
+ break;
328
+ }
329
+ case "":
330
+ case "q":
331
+ case "quit":
332
+ case "exit":
333
+ console.log(color.dim("Thanks for stopping by!"));
334
+ rl.close();
335
+ resolve();
336
+ break;
337
+ default:
338
+ console.log(color.magenta("Invalid option. Please enter 1, 2, 3, 4, or q."));
339
+ ask();
340
+ break;
341
+ }
342
+ });
343
+ };
344
+
345
+ rl.on("close", resolve);
346
+ ask();
347
+ });
348
+ }
349
+
350
+ async function fetchProfile(url, timeoutMs = 3000) {
351
+ let controller;
352
+ let timeoutId;
353
+ if (typeof AbortController !== "undefined") {
354
+ controller = new AbortController();
355
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
356
+ }
357
+
358
+ try {
359
+ if (typeof fetch === "function") {
360
+ const res = await fetch(url, {
361
+ signal: controller ? controller.signal : undefined,
362
+ headers: { "User-Agent": "zephinax-cli-cv" }
363
+ });
364
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
365
+ const json = await res.json();
366
+ return { profile: json.data || json, fromNetwork: true };
367
+ }
368
+
369
+ return await new Promise((resolve, reject) => {
370
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
371
+ let data = "";
372
+ res.on("data", (chunk) => (data += chunk));
373
+ res.on("end", () => {
374
+ if (res.statusCode < 200 || res.statusCode >= 300) {
375
+ return reject(new Error(`HTTP ${res.statusCode}`));
376
+ }
377
+ try {
378
+ const json = JSON.parse(data);
379
+ resolve({ profile: json.data || json, fromNetwork: true });
380
+ } catch (err) {
381
+ reject(err);
382
+ }
383
+ });
384
+ });
385
+ req.on("error", reject);
386
+ req.on("timeout", () => {
387
+ req.destroy();
388
+ reject(new Error("Request timed out"));
389
+ });
390
+ });
169
391
  } catch (err) {
170
- console.error(`${color.magenta("Error:")} ${err.message}`);
171
- process.exitCode = 1;
392
+ return { profile: FALLBACK_PROFILE, fromNetwork: false, error: err.message };
393
+ } finally {
394
+ if (timeoutId) clearTimeout(timeoutId);
395
+ }
396
+ }
397
+
398
+ function showHelp() {
399
+ console.log(`
400
+ ${color.bold("Zephinax (Amirali Rajabi)")} - CLI Portfolio & CV
401
+
402
+ ${color.yellow("Usage:")}
403
+ npx zephinax [options]
404
+
405
+ ${color.yellow("Options:")}
406
+ -s, --static Print card without interactive action prompt
407
+ -j, --json Output profile data in raw JSON format
408
+ -p, --plain Output clean plain text without borders or ANSI colors
409
+ --offline Use embedded profile data without making network requests
410
+ -h, --help Show this help message
411
+ -v, --version Show version number
412
+ `);
413
+ }
414
+
415
+ async function main() {
416
+ const args = process.argv.slice(2);
417
+
418
+ if (args.includes("-h") || args.includes("--help")) {
419
+ showHelp();
420
+ return;
421
+ }
422
+
423
+ if (args.includes("-v") || args.includes("--version")) {
424
+ console.log(PKG.version);
425
+ return;
426
+ }
427
+
428
+ const isJson = args.includes("-j") || args.includes("--json");
429
+ const isPlain = args.includes("-p") || args.includes("--plain");
430
+ const isOffline = args.includes("--offline");
431
+ const isStatic =
432
+ args.includes("-s") ||
433
+ args.includes("--static") ||
434
+ args.includes("--no-interactive");
435
+
436
+ let profile;
437
+ let fromNetwork = false;
438
+
439
+ if (isOffline) {
440
+ profile = FALLBACK_PROFILE;
441
+ } else {
442
+ const res = await fetchProfile(API_URL);
443
+ profile = { ...FALLBACK_PROFILE, ...res.profile };
444
+ fromNetwork = res.fromNetwork;
445
+ }
446
+
447
+ if (isJson) {
448
+ const { about, quote } = parseAboutAndQuote(profile.about);
449
+ console.log(
450
+ JSON.stringify(
451
+ {
452
+ ...profile,
453
+ skills: profile.skills || DEFAULT_SKILLS,
454
+ quote: profile.quote || quote,
455
+ about: about || profile.about,
456
+ source: fromNetwork ? "network" : "offline_cache"
457
+ },
458
+ null,
459
+ 2
460
+ )
461
+ );
462
+ return;
463
+ }
464
+
465
+ if (isPlain) {
466
+ console.log(renderPlain(profile));
467
+ return;
468
+ }
469
+
470
+ console.log(render(profile));
471
+
472
+ const isInteractiveTerminal = Boolean(process.stdin.isTTY && process.stdout.isTTY);
473
+ if (isInteractiveTerminal && !isStatic) {
474
+ await runInteractive(profile);
172
475
  }
173
476
  }
174
477
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zephinax",
3
- "version": "1.0.0",
4
- "description": "CLI portfolio for Zephinax. Fetches live profile data and prints it to the terminal.",
3
+ "version": "1.1.0",
4
+ "description": "Interactive CLI portfolio and CV for Amirali Rajabi (Zephinax).",
5
5
  "bin": {
6
6
  "zephinax": "bin/index.js"
7
7
  },
@@ -11,10 +11,22 @@
11
11
  "keywords": [
12
12
  "portfolio",
13
13
  "cli",
14
- "zephinax"
14
+ "cv",
15
+ "resume",
16
+ "zephinax",
17
+ "terminal-card",
18
+ "interactive"
15
19
  ],
16
20
  "author": "Zephinax <zephinax@gmail.com>",
17
21
  "license": "MIT",
22
+ "homepage": "https://zephinax.com",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/zephinax/zephinax.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/zephinax/zephinax/issues"
29
+ },
18
30
  "engines": {
19
31
  "node": ">=18"
20
32
  }