From 081d32d50c62ee6c1ca682cda8ab00d785c9993e Mon Sep 17 00:00:00 2001 From: Advait Paliwal Date: Wed, 25 Mar 2026 11:08:54 -0700 Subject: [PATCH] Add status command, strip raw blobs from search, harden login flow - Add `alpha status` command to check auth state - Search results drop raw text blobs by default (opt-in via includeRaw) - Clean and normalize search result fields - Export parsePaperSearchResults for external consumers - Login callback server handles EADDRINUSE, styled HTML pages - Better browser open error handling on all platforms - Search prints mode description before results Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 ++ cli/README.md | 1 + cli/src/commands/login.js | 18 ++++++++++- cli/src/commands/search.js | 20 ++++++++++++- cli/src/index.js | 4 ++- cli/src/lib/auth.js | 61 +++++++++++++++++++++++++++++++++----- cli/src/lib/index.d.ts | 9 ++++++ cli/src/lib/index.js | 48 +++++++++++++++++++----------- 8 files changed, 134 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 900320c..f267867 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Research agents hallucinate paper details and forget what they learn in a sessio ```bash npm install -g @companion-ai/alpha-hub alpha login # sign in with alphaXiv +alpha status # show whether alphaXiv auth is present alpha search "attention mechanism" # search papers alpha get 1706.03762 # fetch paper report ``` @@ -52,6 +53,7 @@ alpha ask 1706.03762 "What datasets were used for evaluation?" | `alpha annotate --clear` | Remove a note | | `alpha annotate --list` | List all notes | | `alpha login` | Sign in with alphaXiv | +| `alpha status` | Show alphaXiv authentication status | | `alpha logout` | Sign out | All commands accept `--json` for machine-readable output. diff --git a/cli/README.md b/cli/README.md index d721d61..1abb260 100644 --- a/cli/README.md +++ b/cli/README.md @@ -12,6 +12,7 @@ npm install -g @companion-ai/alpha-hub ```bash alpha login +alpha status alpha search "attention mechanism" alpha get 1706.03762 alpha ask 1706.03762 "What datasets were used for evaluation?" diff --git a/cli/src/commands/login.js b/cli/src/commands/login.js index 2524d9c..68a0ca7 100644 --- a/cli/src/commands/login.js +++ b/cli/src/commands/login.js @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { login, isLoggedIn, logout } from '../lib/auth.js'; +import { getUserName, login, isLoggedIn, logout } from '../lib/auth.js'; export function registerLoginCommand(program) { program @@ -29,3 +29,19 @@ export function registerLogoutCommand(program) { console.log(chalk.green('Logged out')); }); } + +export function registerStatusCommand(program) { + program + .command('status') + .description('Show alphaXiv authentication status') + .action(() => { + if (!isLoggedIn()) { + process.stderr.write(chalk.dim('Not logged in to alphaXiv.\n')); + process.exitCode = 1; + return; + } + + const name = getUserName(); + console.log(chalk.green(name ? `Logged in to alphaXiv as ${name}` : 'Logged in to alphaXiv')); + }); +} diff --git a/cli/src/commands/search.js b/cli/src/commands/search.js index 0b236bf..d9d8a41 100644 --- a/cli/src/commands/search.js +++ b/cli/src/commands/search.js @@ -1,12 +1,27 @@ import chalk from 'chalk'; import { searchByEmbedding, searchByKeyword, agenticSearch, disconnect } from '../lib/alphaxiv.js'; -import { output, error } from '../lib/output.js'; +import { output, error, info } from '../lib/output.js'; function formatResults(data) { const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); console.log(text); } +function describeMode(mode) { + switch (mode) { + case 'keyword': + return 'keyword full-text'; + case 'agentic': + return 'agentic'; + case 'both': + return 'semantic + keyword'; + case 'all': + return 'semantic + keyword + agentic'; + default: + return 'semantic'; + } +} + export function registerSearchCommand(program) { program .command('search ') @@ -15,6 +30,9 @@ export function registerSearchCommand(program) { .action(async (query, cmdOpts) => { const opts = { ...program.opts(), ...cmdOpts }; try { + if (!opts.json) { + info(chalk.dim(`Searching alphaXiv (${describeMode(opts.mode)})...`)); + } let results; if (opts.mode === 'keyword') { results = await searchByKeyword(query); diff --git a/cli/src/index.js b/cli/src/index.js index cfb4c3c..96c6e83 100644 --- a/cli/src/index.js +++ b/cli/src/index.js @@ -8,7 +8,7 @@ import { registerGetCommand } from './commands/get.js'; import { registerAskCommand } from './commands/ask.js'; import { registerAnnotateCommand } from './commands/annotate.js'; import { registerCodeCommand } from './commands/code.js'; -import { registerLoginCommand, registerLogoutCommand } from './commands/login.js'; +import { registerLoginCommand, registerLogoutCommand, registerStatusCommand } from './commands/login.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); @@ -34,6 +34,7 @@ ${chalk.bold.underline('Usage')} ${chalk.bold.underline('Commands')} ${chalk.bold('login')} Log in to alphaXiv (opens browser) + ${chalk.bold('status')} Show alphaXiv authentication status ${chalk.bold('logout')} Log out ${chalk.bold('search')} Search papers (semantic, keyword, both, agentic, or all) ${chalk.bold('get')} Paper content + local annotation @@ -63,6 +64,7 @@ program }); registerLoginCommand(program); +registerStatusCommand(program); registerLogoutCommand(program); registerSearchCommand(program); registerGetCommand(program); diff --git a/cli/src/lib/auth.js b/cli/src/lib/auth.js index f22683a..2c386ea 100644 --- a/cli/src/lib/auth.js +++ b/cli/src/lib/auth.js @@ -81,10 +81,56 @@ function generatePKCE() { } function openBrowser(url) { - const plat = platform(); - if (plat === 'darwin') execSync(`open "${url}"`); - else if (plat === 'linux') execSync(`xdg-open "${url}"`); - else if (plat === 'win32') execSync(`start "${url}"`); + try { + const plat = platform(); + if (plat === 'darwin') execSync(`open "${url}"`); + else if (plat === 'linux') execSync(`xdg-open "${url}"`); + else if (plat === 'win32') execSync(`start "" "${url}"`); + } catch {} +} + +const SUCCESS_HTML = ` + +alphaXiv + + +

Logged in to alphaXiv

You can close this tab

+ +`; + +const ERROR_HTML = ` + +alphaXiv + + +

Login failed

You can close this tab and try again

`; + +function startCallbackServer() { + return new Promise((resolve, reject) => { + const server = createServer(); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${CALLBACK_PORT} is already in use. Close the process using it and try again.`)); + } else { + reject(err); + } + }); + + server.listen(CALLBACK_PORT, '127.0.0.1', () => { + resolve(server); + }); + }); } function waitForCallback(server) { @@ -108,7 +154,7 @@ function waitForCallback(server) { if (error) { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end('

Login failed

You can close this tab.

'); + res.end(ERROR_HTML); clearTimeout(timeout); server.close(); reject(new Error(`OAuth error: ${error}`)); @@ -117,7 +163,7 @@ function waitForCallback(server) { if (code) { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end('

Logged in to Alpha Hub

You can close this tab.

'); + res.end(SUCCESS_HTML); clearTimeout(timeout); server.close(); resolve(code); @@ -194,8 +240,7 @@ export async function login() { authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('state', state); - const server = createServer(); - server.listen(CALLBACK_PORT); + const server = await startCallbackServer(); process.stderr.write('Opening browser for alphaXiv login...\n'); openBrowser(authUrl.toString()); diff --git a/cli/src/lib/index.d.ts b/cli/src/lib/index.d.ts index f442720..bec14d1 100644 --- a/cli/src/lib/index.d.ts +++ b/cli/src/lib/index.d.ts @@ -13,9 +13,18 @@ export declare function searchByEmbedding(query: string): Promise; export declare function searchByKeyword(query: string): Promise; export declare function agenticSearch(query: string): Promise; +export declare function parsePaperSearchResults( + text: unknown, + options?: { includeRaw?: boolean }, +): { + results: unknown[]; + raw?: unknown; +}; + export declare function searchPapers( query: string, mode?: "semantic" | "keyword" | "both" | "agentic" | "all" | string, + options?: { includeRaw?: boolean }, ): Promise; export declare function getPaper( diff --git a/cli/src/lib/index.js b/cli/src/lib/index.js index edeea30..6d28bc5 100644 --- a/cli/src/lib/index.js +++ b/cli/src/lib/index.js @@ -45,9 +45,21 @@ function parsePublishedAt(fragment) { return match ? match[1].trim() : null; } -function parsePaperListText(text) { +function cleanSearchField(value) { + if (typeof value !== 'string') return null; + const normalized = value + .replace(/\r\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/\s*\n\s*/g, ' ') + .replace(/[ \t]+/g, ' ') + .trim(); + return normalized || null; +} + +export function parsePaperSearchResults(text, options = {}) { + const includeRaw = options.includeRaw === true; if (typeof text !== 'string') { - return { raw: text, results: [] }; + return { results: [] }; } const blocks = text @@ -69,28 +81,28 @@ function parsePaperListText(text) { return { rank: index + 1, - title: headerMatch ? headerMatch[1].trim() : header, + title: cleanSearchField(headerMatch ? headerMatch[1] : header), visits: headerMatch ? parseMetricNumber(headerMatch[2], 'Visits') : null, likes: headerMatch ? parseMetricNumber(headerMatch[2], 'Likes') : null, publishedAt: headerMatch ? parsePublishedAt(headerMatch[2]) : null, - organizations: fieldValue('- Organizations:'), - authors: fieldValue('- Authors:'), - abstract: fieldValue('- Abstract:'), - arxivId, + organizations: cleanSearchField(fieldValue('- Organizations:')), + authors: cleanSearchField(fieldValue('- Authors:')), + abstract: cleanSearchField(fieldValue('- Abstract:')), + arxivId: cleanSearchField(arxivId), arxivUrl: arxivId ? `https://arxiv.org/abs/${arxivId}` : null, alphaXivUrl: arxivId ? `https://www.alphaxiv.org/overview/${arxivId}` : null, - raw: block, + ...(includeRaw ? { raw: block } : {}), }; }); - return { raw: text, results }; + return includeRaw ? { raw: text, results } : { results }; } -function normalizeSearchPayload(query, mode, payload) { +function normalizeSearchPayload(query, mode, payload, options = {}) { if (mode === 'all' || mode === 'both') { const normalized = {}; for (const [key, value] of Object.entries(payload)) { - normalized[key] = parsePaperListText(value); + normalized[key] = parsePaperSearchResults(value, options); } return { query, @@ -99,7 +111,7 @@ function normalizeSearchPayload(query, mode, payload) { }; } - const parsed = parsePaperListText(payload); + const parsed = parsePaperSearchResults(payload, options); return { query, mode, @@ -107,18 +119,18 @@ function normalizeSearchPayload(query, mode, payload) { }; } -export async function searchPapers(query, mode = 'semantic') { - if (mode === 'keyword') return normalizeSearchPayload(query, mode, await searchByKeyword(query)); - if (mode === 'agentic') return normalizeSearchPayload(query, mode, await agenticSearch(query)); +export async function searchPapers(query, mode = 'semantic', options = {}) { + if (mode === 'keyword') return normalizeSearchPayload(query, mode, await searchByKeyword(query), options); + if (mode === 'agentic') return normalizeSearchPayload(query, mode, await agenticSearch(query), options); if (mode === 'both') { const [semantic, keyword] = await Promise.all([ searchByEmbedding(query), searchByKeyword(query), ]); - return normalizeSearchPayload(query, mode, { semantic, keyword }); + return normalizeSearchPayload(query, mode, { semantic, keyword }, options); } - if (mode === 'all') return normalizeSearchPayload(query, mode, await searchAll(query)); - return normalizeSearchPayload(query, mode, await searchByEmbedding(query)); + if (mode === 'all') return normalizeSearchPayload(query, mode, await searchAll(query), options); + return normalizeSearchPayload(query, mode, await searchByEmbedding(query), options); } export async function getPaper(identifier, options = {}) {