diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..99f586d --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,40 @@ +name: NPM Publish + +on: + release: + types: [created] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run lint and tests + env: + BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + JINA_API_KEY: ${{ secrets.JINA_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + npm run lint + npm test + + - name: Build TypeScript + run: npm run build + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4edcab2..a33cf3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,4 +31,5 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} JINA_API_KEY: ${{ secrets.JINA_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: npm test diff --git a/package-lock.json b/package-lock.json index 466f51b..f7ab0e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/node-fetch": "^2.6.12", "ai": "^4.1.21", "axios": "^1.7.9", + "commander": "^13.1.0", "cors": "^2.8.5", "duck-duck-scrape": "^2.2.7", "express": "^4.21.2", @@ -24,6 +25,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/commander": "^2.12.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.10", "@typescript-eslint/eslint-plugin": "^7.0.1", @@ -1529,6 +1531,16 @@ "@types/node": "*" } }, + "node_modules/@types/commander": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.0.tgz", + "integrity": "sha512-DDmRkovH7jPjnx7HcbSnqKg2JeNANyxNZeUvB0iE+qKBLN+vzN5iSIwt+J2PFSmBuYEut4mgQvI/fTX9YQH/vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2595,6 +2607,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 01e4224..f8a39c0 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,15 @@ { "name": "node-deepresearch", "version": "1.0.0", - "main": "index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], "scripts": { + "prepare": "npm run build", "build": "tsc", "dev": "npx ts-node src/agent.ts", "search": "npx ts-node src/test-duck.ts", @@ -25,6 +32,7 @@ "@types/node-fetch": "^2.6.12", "ai": "^4.1.21", "axios": "^1.7.9", + "commander": "^13.1.0", "cors": "^2.8.5", "duck-duck-scrape": "^2.2.7", "express": "^4.21.2", @@ -33,6 +41,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/commander": "^2.12.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.10", "@typescript-eslint/eslint-plugin": "^7.0.1", diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts new file mode 100644 index 0000000..7051679 --- /dev/null +++ b/src/__tests__/cli.test.ts @@ -0,0 +1,40 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +// Mock environment variables +process.env.GEMINI_API_KEY = 'test-key'; +process.env.JINA_API_KEY = 'test-key'; + +jest.mock('../agent', () => ({ + getResponse: jest.fn().mockResolvedValue({ + result: { + action: 'answer', + answer: 'Test answer', + references: [] + } + }) +})); + +describe('CLI', () => { + test('shows version', async () => { + const { stdout } = await execAsync('ts-node src/cli.ts --version'); + expect(stdout.trim()).toMatch(/\d+\.\d+\.\d+/); + }); + + test('shows help', async () => { + const { stdout } = await execAsync('ts-node src/cli.ts --help'); + expect(stdout).toContain('deepresearch'); + expect(stdout).toContain('AI-powered research assistant'); + }); + + test('handles invalid token budget', async () => { + try { + await execAsync('ts-node src/cli.ts -t invalid "test query"'); + fail('Should have thrown'); + } catch (error) { + expect((error as { stderr: string }).stderr).toContain('Invalid token budget: must be a number'); + } + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..4764a8b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { getResponse } from './agent'; +import { version } from '../package.json'; + +const program = new Command(); + +program + .name('deepresearch') + .description('AI-powered research assistant that keeps searching until it finds the answer') + .version(version) + .argument('', 'The research query to investigate') + .option('-t, --token-budget ', 'Maximum token budget', (val) => { + const num = parseInt(val); + if (isNaN(num)) throw new Error('Invalid token budget: must be a number'); + return num; + }, 1000000) + .option('-m, --max-attempts ', 'Maximum bad attempts before giving up', (val) => { + const num = parseInt(val); + if (isNaN(num)) throw new Error('Invalid max attempts: must be a number'); + return num; + }, 3) + .option('-v, --verbose', 'Show detailed progress') + .action(async (query: string, options: any) => { + try { + const { result } = await getResponse( + query, + parseInt(options.tokenBudget), + parseInt(options.maxAttempts) + ); + + if (result.action === 'answer') { + console.log('\nAnswer:', result.answer); + if (result.references?.length) { + console.log('\nReferences:'); + result.references.forEach(ref => { + console.log(`- ${ref.url}`); + console.log(` "${ref.exactQuote}"`); + }); + } + } + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); + +program.parse(); diff --git a/tsconfig.json b/tsconfig.json index 90390cc..6b6d13c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,8 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "strict": true + "strict": true, + "resolveJsonModule": true, + "moduleResolution": "node" } -} \ No newline at end of file +}