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) <noreply@anthropic.com>
This commit is contained in:
Advait Paliwal 2026-03-25 11:08:54 -07:00
parent f6469556f0
commit 081d32d50c
8 changed files with 134 additions and 29 deletions

View file

@ -10,6 +10,7 @@ Research agents hallucinate paper details and forget what they learn in a sessio
```bash ```bash
npm install -g @companion-ai/alpha-hub npm install -g @companion-ai/alpha-hub
alpha login # sign in with alphaXiv alpha login # sign in with alphaXiv
alpha status # show whether alphaXiv auth is present
alpha search "attention mechanism" # search papers alpha search "attention mechanism" # search papers
alpha get 1706.03762 # fetch paper report alpha get 1706.03762 # fetch paper report
``` ```
@ -52,6 +53,7 @@ alpha ask 1706.03762 "What datasets were used for evaluation?"
| `alpha annotate <id> --clear` | Remove a note | | `alpha annotate <id> --clear` | Remove a note |
| `alpha annotate --list` | List all notes | | `alpha annotate --list` | List all notes |
| `alpha login` | Sign in with alphaXiv | | `alpha login` | Sign in with alphaXiv |
| `alpha status` | Show alphaXiv authentication status |
| `alpha logout` | Sign out | | `alpha logout` | Sign out |
All commands accept `--json` for machine-readable output. All commands accept `--json` for machine-readable output.

View file

@ -12,6 +12,7 @@ npm install -g @companion-ai/alpha-hub
```bash ```bash
alpha login alpha login
alpha status
alpha search "attention mechanism" alpha search "attention mechanism"
alpha get 1706.03762 alpha get 1706.03762
alpha ask 1706.03762 "What datasets were used for evaluation?" alpha ask 1706.03762 "What datasets were used for evaluation?"

View file

@ -1,5 +1,5 @@
import chalk from 'chalk'; 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) { export function registerLoginCommand(program) {
program program
@ -29,3 +29,19 @@ export function registerLogoutCommand(program) {
console.log(chalk.green('Logged out')); 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'));
});
}

View file

@ -1,12 +1,27 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { searchByEmbedding, searchByKeyword, agenticSearch, disconnect } from '../lib/alphaxiv.js'; 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) { function formatResults(data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
console.log(text); 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) { export function registerSearchCommand(program) {
program program
.command('search <query>') .command('search <query>')
@ -15,6 +30,9 @@ export function registerSearchCommand(program) {
.action(async (query, cmdOpts) => { .action(async (query, cmdOpts) => {
const opts = { ...program.opts(), ...cmdOpts }; const opts = { ...program.opts(), ...cmdOpts };
try { try {
if (!opts.json) {
info(chalk.dim(`Searching alphaXiv (${describeMode(opts.mode)})...`));
}
let results; let results;
if (opts.mode === 'keyword') { if (opts.mode === 'keyword') {
results = await searchByKeyword(query); results = await searchByKeyword(query);

View file

@ -8,7 +8,7 @@ import { registerGetCommand } from './commands/get.js';
import { registerAskCommand } from './commands/ask.js'; import { registerAskCommand } from './commands/ask.js';
import { registerAnnotateCommand } from './commands/annotate.js'; import { registerAnnotateCommand } from './commands/annotate.js';
import { registerCodeCommand } from './commands/code.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 __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@ -34,6 +34,7 @@ ${chalk.bold.underline('Usage')}
${chalk.bold.underline('Commands')} ${chalk.bold.underline('Commands')}
${chalk.bold('login')} Log in to alphaXiv (opens browser) ${chalk.bold('login')} Log in to alphaXiv (opens browser)
${chalk.bold('status')} Show alphaXiv authentication status
${chalk.bold('logout')} Log out ${chalk.bold('logout')} Log out
${chalk.bold('search')} <query> Search papers (semantic, keyword, both, agentic, or all) ${chalk.bold('search')} <query> Search papers (semantic, keyword, both, agentic, or all)
${chalk.bold('get')} <url|arxiv-id> Paper content + local annotation ${chalk.bold('get')} <url|arxiv-id> Paper content + local annotation
@ -63,6 +64,7 @@ program
}); });
registerLoginCommand(program); registerLoginCommand(program);
registerStatusCommand(program);
registerLogoutCommand(program); registerLogoutCommand(program);
registerSearchCommand(program); registerSearchCommand(program);
registerGetCommand(program); registerGetCommand(program);

View file

@ -81,10 +81,56 @@ function generatePKCE() {
} }
function openBrowser(url) { function openBrowser(url) {
const plat = platform(); try {
if (plat === 'darwin') execSync(`open "${url}"`); const plat = platform();
else if (plat === 'linux') execSync(`xdg-open "${url}"`); if (plat === 'darwin') execSync(`open "${url}"`);
else if (plat === 'win32') execSync(`start "${url}"`); else if (plat === 'linux') execSync(`xdg-open "${url}"`);
else if (plat === 'win32') execSync(`start "" "${url}"`);
} catch {}
}
const SUCCESS_HTML = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>alphaXiv</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #e5e5e5; }
.card { text-align: center; padding: 2rem; }
h2 { color: #10b981; margin-bottom: 0.5rem; }
p { color: #737373; }
</style>
</head>
<body><div class="card"><h2>Logged in to alphaXiv</h2><p>You can close this tab</p></div>
<script>setTimeout(function(){window.close()},2000)</script>
</body></html>`;
const ERROR_HTML = `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>alphaXiv</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #0a0a0a; color: #e5e5e5; }
.card { text-align: center; padding: 2rem; }
h2 { color: #ef4444; margin-bottom: 0.5rem; }
p { color: #737373; }
</style>
</head>
<body><div class="card"><h2>Login failed</h2><p>You can close this tab and try again</p></div></body></html>`;
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) { function waitForCallback(server) {
@ -108,7 +154,7 @@ function waitForCallback(server) {
if (error) { if (error) {
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h2>Login failed</h2><p>You can close this tab.</p></body></html>'); res.end(ERROR_HTML);
clearTimeout(timeout); clearTimeout(timeout);
server.close(); server.close();
reject(new Error(`OAuth error: ${error}`)); reject(new Error(`OAuth error: ${error}`));
@ -117,7 +163,7 @@ function waitForCallback(server) {
if (code) { if (code) {
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h2>Logged in to Alpha Hub</h2><p>You can close this tab.</p></body></html>'); res.end(SUCCESS_HTML);
clearTimeout(timeout); clearTimeout(timeout);
server.close(); server.close();
resolve(code); resolve(code);
@ -194,8 +240,7 @@ export async function login() {
authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', state); authUrl.searchParams.set('state', state);
const server = createServer(); const server = await startCallbackServer();
server.listen(CALLBACK_PORT);
process.stderr.write('Opening browser for alphaXiv login...\n'); process.stderr.write('Opening browser for alphaXiv login...\n');
openBrowser(authUrl.toString()); openBrowser(authUrl.toString());

View file

@ -13,9 +13,18 @@ export declare function searchByEmbedding(query: string): Promise<unknown>;
export declare function searchByKeyword(query: string): Promise<unknown>; export declare function searchByKeyword(query: string): Promise<unknown>;
export declare function agenticSearch(query: string): Promise<unknown>; export declare function agenticSearch(query: string): Promise<unknown>;
export declare function parsePaperSearchResults(
text: unknown,
options?: { includeRaw?: boolean },
): {
results: unknown[];
raw?: unknown;
};
export declare function searchPapers( export declare function searchPapers(
query: string, query: string,
mode?: "semantic" | "keyword" | "both" | "agentic" | "all" | string, mode?: "semantic" | "keyword" | "both" | "agentic" | "all" | string,
options?: { includeRaw?: boolean },
): Promise<unknown>; ): Promise<unknown>;
export declare function getPaper( export declare function getPaper(

View file

@ -45,9 +45,21 @@ function parsePublishedAt(fragment) {
return match ? match[1].trim() : null; 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') { if (typeof text !== 'string') {
return { raw: text, results: [] }; return { results: [] };
} }
const blocks = text const blocks = text
@ -69,28 +81,28 @@ function parsePaperListText(text) {
return { return {
rank: index + 1, rank: index + 1,
title: headerMatch ? headerMatch[1].trim() : header, title: cleanSearchField(headerMatch ? headerMatch[1] : header),
visits: headerMatch ? parseMetricNumber(headerMatch[2], 'Visits') : null, visits: headerMatch ? parseMetricNumber(headerMatch[2], 'Visits') : null,
likes: headerMatch ? parseMetricNumber(headerMatch[2], 'Likes') : null, likes: headerMatch ? parseMetricNumber(headerMatch[2], 'Likes') : null,
publishedAt: headerMatch ? parsePublishedAt(headerMatch[2]) : null, publishedAt: headerMatch ? parsePublishedAt(headerMatch[2]) : null,
organizations: fieldValue('- Organizations:'), organizations: cleanSearchField(fieldValue('- Organizations:')),
authors: fieldValue('- Authors:'), authors: cleanSearchField(fieldValue('- Authors:')),
abstract: fieldValue('- Abstract:'), abstract: cleanSearchField(fieldValue('- Abstract:')),
arxivId, arxivId: cleanSearchField(arxivId),
arxivUrl: arxivId ? `https://arxiv.org/abs/${arxivId}` : null, arxivUrl: arxivId ? `https://arxiv.org/abs/${arxivId}` : null,
alphaXivUrl: arxivId ? `https://www.alphaxiv.org/overview/${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') { if (mode === 'all' || mode === 'both') {
const normalized = {}; const normalized = {};
for (const [key, value] of Object.entries(payload)) { for (const [key, value] of Object.entries(payload)) {
normalized[key] = parsePaperListText(value); normalized[key] = parsePaperSearchResults(value, options);
} }
return { return {
query, query,
@ -99,7 +111,7 @@ function normalizeSearchPayload(query, mode, payload) {
}; };
} }
const parsed = parsePaperListText(payload); const parsed = parsePaperSearchResults(payload, options);
return { return {
query, query,
mode, mode,
@ -107,18 +119,18 @@ function normalizeSearchPayload(query, mode, payload) {
}; };
} }
export async function searchPapers(query, mode = 'semantic') { export async function searchPapers(query, mode = 'semantic', options = {}) {
if (mode === 'keyword') return normalizeSearchPayload(query, mode, await searchByKeyword(query)); if (mode === 'keyword') return normalizeSearchPayload(query, mode, await searchByKeyword(query), options);
if (mode === 'agentic') return normalizeSearchPayload(query, mode, await agenticSearch(query)); if (mode === 'agentic') return normalizeSearchPayload(query, mode, await agenticSearch(query), options);
if (mode === 'both') { if (mode === 'both') {
const [semantic, keyword] = await Promise.all([ const [semantic, keyword] = await Promise.all([
searchByEmbedding(query), searchByEmbedding(query),
searchByKeyword(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)); if (mode === 'all') return normalizeSearchPayload(query, mode, await searchAll(query), options);
return normalizeSearchPayload(query, mode, await searchByEmbedding(query)); return normalizeSearchPayload(query, mode, await searchByEmbedding(query), options);
} }
export async function getPaper(identifier, options = {}) { export async function getPaper(identifier, options = {}) {