Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
37 changes: 37 additions & 0 deletions scripts/postinstall.js
Original file line number Diff line number Diff line change
@@ -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("");
80 changes: 80 additions & 0 deletions test/bin-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): { 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);
});
});
87 changes: 87 additions & 0 deletions test/postinstall.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): {
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");
});
});