diff --git a/package-lock.json b/package-lock.json index 2243663..5fe6b4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@companion-ai/feynman", - "version": "0.2.13", + "version": "0.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@companion-ai/feynman", - "version": "0.2.13", + "version": "0.2.14", "license": "MIT", "dependencies": { "@companion-ai/alpha-hub": "^0.1.2", diff --git a/package.json b/package.json index 585c629..74a2c29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@companion-ai/feynman", - "version": "0.2.13", + "version": "0.2.14", "description": "Research-first CLI agent built on Pi and alphaXiv", "license": "MIT", "type": "module", diff --git a/src/model/commands.ts b/src/model/commands.ts index b43b132..b0b7629 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -3,6 +3,7 @@ import { writeFileSync } from "node:fs"; import { readJson } from "../pi/settings.js"; import { promptChoice, promptText } from "../setup/prompts.js"; +import { openUrl } from "../system/open-url.js"; import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js"; import { buildModelStatusSnapshotFromRecords, @@ -126,7 +127,13 @@ export async function loginModelProvider(authPath: string, providerId?: string, await authStorage.login(provider.id, { onAuth: (info: { url: string; instructions?: string }) => { printSection(`Login: ${provider.name ?? provider.id}`); - printInfo(`Open this URL: ${info.url}`); + const opened = openUrl(info.url); + if (opened) { + printInfo("Opened the login URL in your browser."); + } else { + printWarning("Couldn't open your browser automatically."); + } + printInfo(`Auth URL: ${info.url}`); if (info.instructions) { printInfo(info.instructions); } diff --git a/src/system/open-url.ts b/src/system/open-url.ts new file mode 100644 index 0000000..49ff854 --- /dev/null +++ b/src/system/open-url.ts @@ -0,0 +1,51 @@ +import { spawn } from "node:child_process"; + +import { resolveExecutable } from "./executables.js"; + +type ResolveExecutableFn = (name: string, fallbackPaths?: string[]) => string | undefined; + +type OpenUrlCommand = { + command: string; + args: string[]; +}; + +export function getOpenUrlCommand( + url: string, + platform = process.platform, + resolveCommand: ResolveExecutableFn = resolveExecutable, +): OpenUrlCommand | undefined { + if (platform === "win32") { + return { + command: "cmd", + args: ["/c", "start", "", url], + }; + } + + if (platform === "darwin") { + const command = resolveCommand("open"); + return command ? { command, args: [url] } : undefined; + } + + const command = resolveCommand("xdg-open"); + return command ? { command, args: [url] } : undefined; +} + +export function openUrl(url: string): boolean { + const command = getOpenUrlCommand(url); + if (!command) { + return false; + } + + try { + const child = spawn(command.command, command.args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.on("error", () => {}); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/tests/open-url.test.ts b/tests/open-url.test.ts new file mode 100644 index 0000000..3f07916 --- /dev/null +++ b/tests/open-url.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getOpenUrlCommand } from "../src/system/open-url.js"; + +test("getOpenUrlCommand uses open on macOS when available", () => { + const command = getOpenUrlCommand( + "https://example.com", + "darwin", + (name) => (name === "open" ? "/usr/bin/open" : undefined), + ); + + assert.deepEqual(command, { + command: "/usr/bin/open", + args: ["https://example.com"], + }); +}); + +test("getOpenUrlCommand uses xdg-open on Linux when available", () => { + const command = getOpenUrlCommand( + "https://example.com", + "linux", + (name) => (name === "xdg-open" ? "/usr/bin/xdg-open" : undefined), + ); + + assert.deepEqual(command, { + command: "/usr/bin/xdg-open", + args: ["https://example.com"], + }); +}); + +test("getOpenUrlCommand uses cmd start on Windows", () => { + const command = getOpenUrlCommand("https://example.com", "win32"); + + assert.deepEqual(command, { + command: "cmd", + args: ["/c", "start", "", "https://example.com"], + }); +}); + +test("getOpenUrlCommand returns undefined when no opener is available", () => { + const command = getOpenUrlCommand("https://example.com", "linux", () => undefined); + + assert.equal(command, undefined); +}); diff --git a/website/src/content/docs/getting-started/installation.md b/website/src/content/docs/getting-started/installation.md index 0ec650c..017b9be 100644 --- a/website/src/content/docs/getting-started/installation.md +++ b/website/src/content/docs/getting-started/installation.md @@ -41,7 +41,7 @@ On Windows: & ([scriptblock]::Create((irm https://feynman.is/install.ps1))) -Version stable ``` -You can also pin an exact version by replacing `stable` with a version such as `0.2.13`. +You can also pin an exact version by replacing `stable` with a version such as `0.2.14`. ## pnpm