diff --git a/bin/cli.mjs b/bin/cli.mjs new file mode 100755 index 0000000..8df48bc --- /dev/null +++ b/bin/cli.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Node.js-compatible wrapper for talent-agent CLI. + * + * talent-agent requires the Bun runtime. This wrapper: + * 1. Checks that `bun` is available on the PATH. + * 2. Spawns the real TypeScript entry point with `bun`. + * 3. Forwards stdio, signals, and exit codes transparently. + */ + +import { spawn, execFileSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const entry = resolve(__dirname, "..", "src", "index.ts"); + +// ─── Verify Bun is installed ──────────────────────────────────────────────── + +try { + execFileSync("bun", ["--version"], { stdio: "ignore" }); +} catch { + console.error( + "talent-agent requires the Bun runtime.\n\n" + + " Install it: curl -fsSL https://bun.sh/install | bash\n" + + " Learn more: https://bun.sh\n", + ); + process.exit(1); +} + +// ─── Spawn Bun with the real entry point ──────────────────────────────────── + +const child = spawn("bun", [entry, ...process.argv.slice(2)], { + stdio: "inherit", +}); + +// Forward signals so Ctrl-C, SIGTERM, etc. reach the child process. +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} + +child.on("close", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } +}); diff --git a/package.json b/package.json index e0b712b..6aada86 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,12 @@ "description": "AI-powered talent search CLI for natural language queries", "type": "module", "bin": { - "talent-agent": "./src/index.ts" + "talent-agent": "./bin/cli.mjs" }, "files": [ "src", - "skills" + "skills", + "bin" ], "exports": { ".": "./src/lib.ts", @@ -49,6 +50,7 @@ "version:packages": "changeset version", "ci:version": "changeset version", "ci:publish": "changeset publish", + "postinstall": "node scripts/postinstall.js || true", "docker:build": "docker build -f docker/Dockerfile -t talent-agent .", "docker:serve": "docker compose -f docker/docker-compose.yml up talent-agent-mcp" }, diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000..29c432a --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +/** + * Post-install script for talent-agent. + * + * Prints a welcome message with getting-started instructions and warns + * if the required Bun runtime is not found on the PATH. + * + * Runs with Node.js (no Bun dependency) so it works in any npm environment. + */ + +import { execFileSync } from "node:child_process"; + +let hasBun = false; +try { + execFileSync("bun", ["--version"], { stdio: "ignore" }); + hasBun = true; +} catch { + // bun not found -- warn below +} + +console.log(""); +console.log(" talent-agent installed successfully!"); +console.log(""); + +if (!hasBun) { + console.log(" Bun runtime is required but was not found."); + console.log(" Install it: curl -fsSL https://bun.sh/install | bash"); + console.log(" Learn more: https://bun.sh"); + console.log(""); +} + +console.log(" Get started:"); +console.log(" talent-agent login Authenticate"); +console.log(' talent-agent "Find devs" Search for talent'); +console.log(" talent-agent --help Show all options"); +console.log(""); diff --git a/test/bin-cli.test.ts b/test/bin-cli.test.ts new file mode 100644 index 0000000..6aab5ec --- /dev/null +++ b/test/bin-cli.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for the Node.js bin wrapper (bin/cli.mjs). + * + * The wrapper delegates to `bun src/index.ts`, so we test it by running + * it as a subprocess with `node` -- the same way npm would invoke it. + */ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const BIN_PATH = resolve(__dirname, "..", "bin", "cli.mjs"); + +/** + * Helper to run the bin wrapper via `node` and capture output. + */ +function runBin( + args: string[], + env?: Record, +): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync("node", [BIN_PATH, ...args], { + env: { + ...process.env, + NO_COLOR: "1", + ...env, + }, + encoding: "utf-8", + timeout: 15000, + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (error: any) { + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + exitCode: error.status ?? 1, + }; + } +} + +describe("bin/cli.mjs wrapper", () => { + it("forwards --version and prints a semver string", () => { + const { stdout, exitCode } = runBin(["--version"]); + + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(exitCode).toBe(0); + }); + + it("forwards --help and prints usage text", () => { + const { stdout, exitCode } = runBin(["--help"]); + + expect(stdout).toContain("Talent Agent CLI"); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("OPTIONS:"); + expect(exitCode).toBe(0); + }); + + it("forwards --help --json and prints JSON schema", () => { + const { stdout, exitCode } = runBin(["--help", "--json"]); + + const parsed = JSON.parse(stdout); + expect(parsed.name).toBe("talent-agent"); + expect(parsed.version).toBeDefined(); + expect(parsed.modes).toBeDefined(); + expect(exitCode).toBe(0); + }); + + it("forwards exit codes for unknown flags", () => { + const { stderr, exitCode } = runBin(["--bad-flag"]); + + expect(stderr).toContain("Unknown flag: --bad-flag"); + expect(exitCode).toBe(2); + }); + + it("forwards -v shorthand", () => { + const { stdout, exitCode } = runBin(["-v"]); + + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/postinstall.test.ts b/test/postinstall.test.ts new file mode 100644 index 0000000..44977f0 --- /dev/null +++ b/test/postinstall.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for the post-install script (scripts/postinstall.js). + * + * Runs the script via `node` as a subprocess to verify its output, + * matching how npm would execute it after `npm install`. + */ +import { execFileSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = resolve(__dirname, "..", "scripts", "postinstall.js"); + +/** + * Build a PATH string that keeps `node` reachable but hides `bun`. + */ +function pathWithoutBun(): string { + const bunDir = dirname( + execFileSync("which", ["bun"], { encoding: "utf-8" }).trim(), + ); + return (process.env.PATH ?? "") + .split(":") + .filter((p) => p !== bunDir) + .join(":"); +} + +/** + * Helper to run the postinstall script and capture output. + */ +function runPostinstall(env?: Record): { + stdout: string; + stderr: string; + exitCode: number; +} { + try { + const stdout = execFileSync("node", [SCRIPT_PATH], { + env: { + ...process.env, + ...env, + }, + encoding: "utf-8", + timeout: 10000, + }); + return { stdout, stderr: "", exitCode: 0 }; + } catch (error: any) { + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + exitCode: error.status ?? 1, + }; + } +} + +describe("scripts/postinstall.js", () => { + it("exits with code 0", () => { + const { exitCode } = runPostinstall(); + + expect(exitCode).toBe(0); + }); + + it("prints the success message", () => { + const { stdout } = runPostinstall(); + + expect(stdout).toContain("talent-agent installed successfully!"); + }); + + it("prints getting-started instructions", () => { + const { stdout } = runPostinstall(); + + expect(stdout).toContain("talent-agent login"); + expect(stdout).toContain("talent-agent --help"); + expect(stdout).toContain("Get started:"); + }); + + it("does not show the bun warning when bun is available", () => { + const { stdout } = runPostinstall(); + + // bun is available in this dev environment + expect(stdout).not.toContain("Bun runtime is required but was not found"); + }); + + it("shows the bun warning when bun is not on PATH", () => { + const { stdout } = runPostinstall({ PATH: pathWithoutBun() }); + + expect(stdout).toContain("Bun runtime is required but was not found"); + expect(stdout).toContain("curl -fsSL https://bun.sh/install | bash"); + }); +});