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
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,15 @@
"commander": "^14.0.3",
"web-tree-sitter": "^0.26.5"
},
"peerDependencies": {
"@huggingface/transformers": "^3.8.1"
},
"peerDependenciesMeta": {
"@huggingface/transformers": {
"optional": true
}
},
"optionalDependencies": {
"@huggingface/transformers": "^3.8.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@optave/codegraph-darwin-arm64": "2.3.0",
"@optave/codegraph-darwin-x64": "2.3.0",
Expand Down
46 changes: 41 additions & 5 deletions src/embedder.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { createInterface } from 'node:readline';
import Database from 'better-sqlite3';
import { findDbPath, openReadonlyOrFail } from './db.js';
import { warn } from './logger.js';
Expand Down Expand Up @@ -222,18 +224,52 @@ function buildSourceText(node, file, lines) {
return `${node.kind} ${node.name} (${readable}) in ${file}\n${context}`;
}

/**
* Prompt the user to install a missing package interactively.
* Returns true if the package was installed, false otherwise.
* Skips the prompt entirely in non-TTY environments (CI, piped stdin).
*/
function promptInstall(packageName) {
if (!process.stdin.isTTY) return Promise.resolve(false);

return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stderr });
rl.question(`Semantic search requires ${packageName}. Install it now? [y/N] `, (answer) => {
rl.close();
if (answer.trim().toLowerCase() !== 'y') return resolve(false);
try {
execFileSync('npm', ['install', packageName], {
stdio: 'inherit',
timeout: 300_000,
});
resolve(true);
} catch {
resolve(false);
}
});
});
}

/**
* Lazy-load @huggingface/transformers.
* This is an optional dependency — gives a clear error if not installed.
* If the package is missing, prompts the user to install it interactively.
* In non-TTY environments, prints an error and exits.
*/
async function loadTransformers() {
try {
return await import('@huggingface/transformers');
} catch {
console.error(
'Semantic search requires @huggingface/transformers.\n' +
'Install it with: npm install @huggingface/transformers',
);
const pkg = '@huggingface/transformers';
const installed = await promptInstall(pkg);
if (installed) {
try {
return await import(pkg);
} catch {
console.error(`\n${pkg} was installed but failed to load. Please check your environment.`);
process.exit(1);
}
}
console.error(`Semantic search requires ${pkg}.\n` + `Install it with: npm install ${pkg}`);
process.exit(1);
}
}
Expand Down
138 changes: 138 additions & 0 deletions tests/unit/prompt-install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Unit tests for the interactive install prompt in src/embedder.js.
*
* Tests the promptInstall() + loadTransformers() flow when
* @huggingface/transformers is missing.
*
* Each test uses vi.resetModules() + vi.doMock() + dynamic import()
* so every test gets a fresh embedder module with its own mocks.
*/

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

describe('loadTransformers install prompt', () => {
let exitSpy;
let errorSpy;
let logSpy;
let origTTY;

beforeEach(() => {
vi.resetModules();
origTTY = process.stdin.isTTY;
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
process.stdin.isTTY = origTTY;
exitSpy.mockRestore();
errorSpy.mockRestore();
logSpy.mockRestore();
vi.restoreAllMocks();
});

test('non-TTY: prints error and exits without prompting', async () => {
process.stdin.isTTY = undefined;

const rlFactory = vi.fn();
vi.doMock('node:readline', () => ({ createInterface: rlFactory }));
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() }));
vi.doMock('@huggingface/transformers', () => {
throw new Error('Cannot find package');
});

const { embed } = await import('../../src/embedder.js');

await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Semantic search requires @huggingface/transformers'),
);
// readline should NOT have been called — no prompt in non-TTY
expect(rlFactory).not.toHaveBeenCalled();
});

test('TTY + user declines: prints error and exits', async () => {
process.stdin.isTTY = true;

vi.doMock('node:readline', () => ({
createInterface: () => ({
question: (_prompt, cb) => cb('n'),
close: vi.fn(),
}),
}));
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() }));
vi.doMock('@huggingface/transformers', () => {
throw new Error('Cannot find package');
});

const { embed } = await import('../../src/embedder.js');

await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)');
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Semantic search requires @huggingface/transformers'),
);
});

test('TTY + user accepts but npm install fails: prints error and exits', async () => {
process.stdin.isTTY = true;

const execMock = vi.fn(() => {
throw new Error('npm ERR!');
});
vi.doMock('node:readline', () => ({
createInterface: () => ({
question: (_prompt, cb) => cb('y'),
close: vi.fn(),
}),
}));
vi.doMock('node:child_process', () => ({ execFileSync: execMock }));
vi.doMock('@huggingface/transformers', () => {
throw new Error('Cannot find package');
});

const { embed } = await import('../../src/embedder.js');

await expect(embed(['test'], 'minilm')).rejects.toThrow('process.exit(1)');
expect(execMock).toHaveBeenCalledWith(
'npm',
['install', '@huggingface/transformers'],
expect.objectContaining({ stdio: 'inherit', timeout: 300_000 }),
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Semantic search requires @huggingface/transformers'),
);
});

test('TTY + install succeeds: retries import and loads module', async () => {
process.stdin.isTTY = true;

let importCount = 0;
vi.doMock('node:readline', () => ({
createInterface: () => ({
question: (_prompt, cb) => cb('y'),
close: vi.fn(),
}),
}));
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn() }));
vi.doMock('@huggingface/transformers', () => {
importCount++;
if (importCount <= 1) throw new Error('Cannot find package');
return {
pipeline: async () => async (batch) => ({
data: new Float32Array(384 * batch.length),
}),
cos_sim: () => 0,
};
});

const { embed } = await import('../../src/embedder.js');

const result = await embed(['test text'], 'minilm');
expect(result.vectors).toHaveLength(1);
expect(result.dim).toBe(384);
expect(exitSpy).not.toHaveBeenCalled();
});
});