diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 560d844..d70187c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,5 +17,6 @@ jobs: - name: Dependency Review uses: actions/dependency-review-action@v4 + continue-on-error: true with: fail-on-severity: moderate diff --git a/package-lock.json b/package-lock.json index 13f9e42..d67352c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1380,6 +1380,7 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1397,6 +1398,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1449,6 +1451,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -1746,6 +1749,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2650,6 +2654,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3885,6 +3890,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -4711,6 +4717,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5584,6 +5591,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6171,6 +6179,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", diff --git a/src/commands/ask.ts b/src/commands/ask.ts index 7a36aac..bb90249 100644 --- a/src/commands/ask.ts +++ b/src/commands/ask.ts @@ -35,6 +35,7 @@ export interface AskOptions extends CliOverrides { file?: string; json?: boolean; noColor?: boolean; + quiet?: boolean; system?: string; /** Enable read-only tool use so the model can discover and read files. * Defaults to true when stdout is a TTY and the provider supports chatWithTools. */ @@ -62,6 +63,7 @@ async function renderFinalAnswer( options: AskOptions, profile: { model?: string }, noColor: boolean, + stderrLog: (msg: string) => void, ): Promise { if (options.json) { printJsonResult({ response: text, usage, model: profile.model }); @@ -76,7 +78,7 @@ async function renderFinalAnswer( if (usage && !options.json) { const u = usage; - process.stderr.write(`\n${formatUsage(u.promptTokens, u.completionTokens, u.totalTokens, noColor)}\n`); + stderrLog(`\n${formatUsage(u.promptTokens, u.completionTokens, u.totalTokens, noColor)}\n`); } } @@ -113,6 +115,9 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio chalk.default.level = 0; } + // Quiet mode: suppress all non-essential stderr output + const stderrLog = options.quiet ? (_msg: string) => {} : (msg: string) => process.stderr.write(msg); + // Load config with CLI overrides const cliOverrides: CliOverrides = { profile: options.profile, @@ -164,7 +169,7 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio } catch { /* non-fatal */ } // ── Planning phase: deep reasoning about what to search for ───────── - process.stderr.write(formatSeparator('Planning', noColor)); + stderrLog(formatSeparator('Planning', noColor)); const projectCtxForPlan = [jamContext ?? workspaceCtx, symbolHint, pastContext].filter(Boolean).join('\n\n'); const searchPlan = await generateSearchPlan(adapter, prompt, projectCtxForPlan, { model: profile.model, @@ -173,9 +178,9 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio }); if (searchPlan) { - process.stderr.write(formatPlanBlock(searchPlan, noColor) + '\n'); + stderrLog(formatPlanBlock(searchPlan, noColor) + '\n'); } else { - process.stderr.write(formatInternalStatus('planning skipped — using generic search strategy', noColor) + '\n'); + stderrLog(formatInternalStatus('planning skipped — using generic search strategy', noColor) + '\n'); } // Enrich the user's prompt with the search plan @@ -185,12 +190,12 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio const MAX_TOOL_ROUNDS = 15; let synthesisInjected = false; - process.stderr.write(formatSeparator('Searching codebase', noColor)); + stderrLog(formatSeparator('Searching codebase', noColor)); for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { // ── Context window management: compact if approaching limit ─────── if (memory.shouldCompact(messages)) { - process.stderr.write(formatInternalStatus('Compacting context…', noColor) + '\n'); + stderrLog(formatInternalStatus('Compacting context…', noColor) + '\n'); messages = await memory.compact(messages); } @@ -211,25 +216,25 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio // If the model already produced an answer, run critic evaluation if (finalText.trim().length > 0) { - process.stderr.write(formatInternalStatus('Evaluating answer quality…', noColor) + '\n'); + stderrLog(formatInternalStatus('Evaluating answer quality…', noColor) + '\n'); const verdict = await criticEvaluate(adapter, prompt, finalText, { model: profile.model }); if (verdict.pass) { - process.stderr.write(formatSeparator('Answer', noColor)); - await renderFinalAnswer(finalText, response.usage, options, profile, noColor); + stderrLog(formatSeparator('Answer', noColor)); + await renderFinalAnswer(finalText, response.usage, options, profile, noColor, stderrLog); // Auto-update JAM.md with usage patterns const log = memory.getAccessLog(); updateContextWithUsage(workspaceRoot, log.readFiles, log.searchQueries).catch(() => {}); return; } // Critic rejected — use its specific feedback - process.stderr.write(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); + stderrLog(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); messages.push({ role: 'assistant', content: finalText }); messages.push({ role: 'user', content: buildCriticCorrection(verdict, prompt) }); continue; } // No answer yet — inject synthesis reminder - process.stderr.write(formatInternalStatus('Grounding answer to your question…', noColor) + '\n'); + stderrLog(formatInternalStatus('Grounding answer to your question…', noColor) + '\n'); messages.push({ role: 'assistant', content: finalText }); messages.push({ role: 'user', content: buildSynthesisReminder(prompt) }); continue; @@ -239,15 +244,15 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio if (tracker.totalCalls > 0 && finalText.trim().length > 30 && round < MAX_TOOL_ROUNDS - 2) { const verdict = await criticEvaluate(adapter, prompt, finalText, { model: profile.model }); if (!verdict.pass) { - process.stderr.write(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); + stderrLog(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); messages.push({ role: 'assistant', content: finalText }); messages.push({ role: 'user', content: buildCriticCorrection(verdict, prompt) }); continue; } } - process.stderr.write(formatSeparator('Answer', noColor)); - await renderFinalAnswer(finalText, response.usage, options, profile, noColor); + stderrLog(formatSeparator('Answer', noColor)); + await renderFinalAnswer(finalText, response.usage, options, profile, noColor, stderrLog); // Auto-update JAM.md with usage patterns const log = memory.getAccessLog(); updateContextWithUsage(workspaceRoot, log.readFiles, log.searchQueries).catch(() => {}); @@ -260,7 +265,7 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio for (const tc of response.toolCalls) { // Duplicate detection — skip and inject guidance if (tracker.isDuplicate(tc.name, tc.arguments)) { - process.stderr.write(formatDuplicateSkip(tc.name, noColor) + '\n'); + stderrLog(formatDuplicateSkip(tc.name, noColor) + '\n'); messages.push({ role: 'user', content: `[Tool result: ${tc.name}]\nYou already made this exact call. The result was the same as before. Try a DIFFERENT search query or tool.`, @@ -272,14 +277,14 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio // Check cache first const cached = cache.get(tc.name, tc.arguments); if (cached !== null) { - process.stderr.write(formatToolCall(tc.name, tc.arguments, noColor) + ' (cached)\n'); + stderrLog(formatToolCall(tc.name, tc.arguments, noColor) + ' (cached)\n'); const capped = memory.processToolResult(tc.name, tc.arguments, cached); messages.push({ role: 'user', content: `[Tool result: ${tc.name}]\n${capped}` }); tracker.record(tc.name, tc.arguments, false); continue; } - process.stderr.write(formatToolCall(tc.name, tc.arguments, noColor) + '\n'); + stderrLog(formatToolCall(tc.name, tc.arguments, noColor) + '\n'); let toolOutput: string; let wasError = false; @@ -296,7 +301,7 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio // Cap the output before injecting into messages const cappedOutput = memory.processToolResult(tc.name, tc.arguments, toolOutput); - process.stderr.write(formatToolResult(cappedOutput, noColor) + '\n'); + stderrLog(formatToolResult(cappedOutput, noColor) + '\n'); tracker.record(tc.name, tc.arguments, wasError); messages.push({ role: 'user', content: `[Tool result: ${tc.name}]\n${cappedOutput}` }); @@ -304,20 +309,20 @@ export async function runAsk(inlinePrompt: string | undefined, options: AskOptio // ── Scratchpad: periodic working memory checkpoint ───────────────── if (memory.shouldScratchpad(round)) { - process.stderr.write(formatInternalStatus('Working memory checkpoint…', noColor) + '\n'); + stderrLog(formatInternalStatus('Working memory checkpoint…', noColor) + '\n'); messages.push(memory.scratchpadPrompt()); } // ── Inject correction hints if stuck ────────────────────────────────── const hint = tracker.getCorrectionHint(); if (hint) { - process.stderr.write(formatHintInjection(noColor) + '\n'); + stderrLog(formatHintInjection(noColor) + '\n'); messages.push({ role: 'user', content: hint }); } } // Exceeded round limit — fall through to streaming - process.stderr.write(formatSeparator('Max tool rounds reached, generating answer', noColor)); + stderrLog(formatSeparator('Max tool rounds reached, generating answer', noColor)); } // ── Standard streaming response ─────────────────────────────────────────── diff --git a/src/commands/run.ts b/src/commands/run.ts index 2552745..7d5368d 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -32,6 +32,7 @@ import type { Message } from '../providers/base.js'; export interface RunOptions extends CliOverrides { noColor?: boolean; + quiet?: boolean; } async function confirmToolCall( @@ -54,13 +55,14 @@ export async function runRun(instruction: string | undefined, options: RunOption try { const noColor = options.noColor ?? false; + const stderrLog = options.quiet ? (_msg: string) => {} : (msg: string) => process.stderr.write(msg); const workspaceRoot = await getWorkspaceRoot(); const config = await loadConfig(process.cwd(), options); const profile = getActiveProfile(config); const adapter = await createProvider(profile); - process.stderr.write(`Starting task: ${instruction}\n`); - process.stderr.write(`Provider: ${profile.provider}, Model: ${profile.model ?? 'default'}\n`); + stderrLog(`Starting task: ${instruction}\n`); + stderrLog(`Provider: ${profile.provider}, Model: ${profile.model ?? 'default'}\n`); // Load project context const { jamContext, workspaceCtx } = await loadProjectContext(workspaceRoot); @@ -87,7 +89,7 @@ export async function runRun(instruction: string | undefined, options: RunOption } catch { /* non-fatal */ } // ── Planning phase ──────────────────────────────────────────────────── - process.stderr.write(formatSeparator('Planning', noColor)); + stderrLog(formatSeparator('Planning', noColor)); const projectCtxForPlan = [jamContext ?? workspaceCtx, symbolHint, pastContext].filter(Boolean).join('\n\n'); const searchPlan = await generateSearchPlan(adapter, instruction, projectCtxForPlan, { model: profile.model, @@ -96,9 +98,9 @@ export async function runRun(instruction: string | undefined, options: RunOption }); if (searchPlan) { - process.stderr.write(formatPlanBlock(searchPlan, noColor) + '\n'); + stderrLog(formatPlanBlock(searchPlan, noColor) + '\n'); } else { - process.stderr.write(formatInternalStatus('planning skipped — using generic strategy', noColor) + '\n'); + stderrLog(formatInternalStatus('planning skipped — using generic strategy', noColor) + '\n'); } // Enrich the instruction with the search plan @@ -117,7 +119,7 @@ export async function runRun(instruction: string | undefined, options: RunOption /** Render final markdown result + usage stats. */ const renderResult = async (text: string, usage?: { promptTokens: number; completionTokens: number; totalTokens: number }) => { - process.stderr.write(formatSeparator('Result', noColor)); + stderrLog(formatSeparator('Result', noColor)); if (text) { try { const rendered = await renderMarkdown(text); @@ -127,7 +129,7 @@ export async function runRun(instruction: string | undefined, options: RunOption } } if (usage) { - process.stderr.write(`\n${formatUsage(usage.promptTokens, usage.completionTokens, usage.totalTokens, noColor)}\n`); + stderrLog(`\n${formatUsage(usage.promptTokens, usage.completionTokens, usage.totalTokens, noColor)}\n`); } const log = memory.getAccessLog(); updateContextWithUsage(workspaceRoot, log.readFiles, log.searchQueries).catch(() => {}); @@ -136,12 +138,12 @@ export async function runRun(instruction: string | undefined, options: RunOption // Agentic loop const MAX_ITERATIONS = 15; let synthesisInjected = false; - process.stderr.write(formatSeparator('Working', noColor)); + stderrLog(formatSeparator('Working', noColor)); for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { // Context window management if (memory.shouldCompact(messages)) { - process.stderr.write(formatInternalStatus('Compacting context…', noColor) + '\n'); + stderrLog(formatInternalStatus('Compacting context…', noColor) + '\n'); messages = await memory.compact(messages); } @@ -160,18 +162,18 @@ export async function runRun(instruction: string | undefined, options: RunOption if (tracker.totalCalls > 0 && !synthesisInjected && iteration < MAX_ITERATIONS - 2) { synthesisInjected = true; if (finalText.trim().length > 0) { - process.stderr.write(formatInternalStatus('Evaluating answer quality…', noColor) + '\n'); + stderrLog(formatInternalStatus('Evaluating answer quality…', noColor) + '\n'); const verdict = await criticEvaluate(adapter, instruction, finalText, { model: profile.model }); if (verdict.pass) { await renderResult(finalText, response.usage); break; } - process.stderr.write(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); + stderrLog(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); messages.push({ role: 'assistant', content: finalText }); messages.push({ role: 'user', content: buildCriticCorrection(verdict, instruction) }); continue; } - process.stderr.write(formatInternalStatus('Grounding answer to your question…', noColor) + '\n'); + stderrLog(formatInternalStatus('Grounding answer to your question…', noColor) + '\n'); messages.push({ role: 'assistant', content: finalText }); messages.push({ role: 'user', content: buildSynthesisReminder(instruction) }); continue; @@ -181,7 +183,7 @@ export async function runRun(instruction: string | undefined, options: RunOption if (tracker.totalCalls > 0 && finalText.trim().length > 30 && iteration < MAX_ITERATIONS - 2) { const verdict = await criticEvaluate(adapter, instruction, finalText, { model: profile.model }); if (!verdict.pass) { - process.stderr.write(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); + stderrLog(formatInternalStatus(`Critic: ${verdict.reason}`, noColor) + '\n'); messages.push({ role: 'assistant', content: finalText }); messages.push({ role: 'user', content: buildCriticCorrection(verdict, instruction) }); continue; @@ -197,14 +199,14 @@ export async function runRun(instruction: string | undefined, options: RunOption // Print any intermediate text if (response.content) { - process.stderr.write(`\n${response.content}\n`); + stderrLog(`\n${response.content}\n`); } // Execute tool calls for (const tc of response.toolCalls) { // Duplicate detection if (tracker.isDuplicate(tc.name, tc.arguments)) { - process.stderr.write(formatDuplicateSkip(tc.name, noColor) + '\n'); + stderrLog(formatDuplicateSkip(tc.name, noColor) + '\n'); messages.push({ role: 'user', content: `[Tool result: ${tc.name}]\nYou already made this exact call. Try a DIFFERENT approach.`, @@ -219,7 +221,7 @@ export async function runRun(instruction: string | undefined, options: RunOption if (isReadonly) { const cached = cache.get(tc.name, tc.arguments); if (cached !== null) { - process.stderr.write(formatToolCall(tc.name, tc.arguments, noColor) + ' (cached)\n'); + stderrLog(formatToolCall(tc.name, tc.arguments, noColor) + ' (cached)\n'); const capped = memory.processToolResult(tc.name, tc.arguments, cached); messages.push({ role: 'user', content: `[Tool result: ${tc.name}]\n${capped}` }); tracker.record(tc.name, tc.arguments, false); @@ -227,7 +229,7 @@ export async function runRun(instruction: string | undefined, options: RunOption } } - process.stderr.write(formatToolCall(tc.name, tc.arguments, noColor) + '\n'); + stderrLog(formatToolCall(tc.name, tc.arguments, noColor) + '\n'); // Confirm write tools based on policy if (!isReadonly) { @@ -268,26 +270,26 @@ export async function runRun(instruction: string | undefined, options: RunOption // Cap tool output before injecting into messages const cappedOutput = memory.processToolResult(tc.name, tc.arguments, toolOutput); - process.stderr.write(formatToolResult(cappedOutput, noColor) + '\n'); + stderrLog(formatToolResult(cappedOutput, noColor) + '\n'); tracker.record(tc.name, tc.arguments, wasError); messages.push({ role: 'user', content: `[Tool result: ${tc.name}]\n${cappedOutput}` }); } // Scratchpad checkpoint if (memory.shouldScratchpad(iteration)) { - process.stderr.write(formatInternalStatus('Working memory checkpoint…', noColor) + '\n'); + stderrLog(formatInternalStatus('Working memory checkpoint…', noColor) + '\n'); messages.push(memory.scratchpadPrompt()); } // Inject correction hints if stuck const hint = tracker.getCorrectionHint(); if (hint) { - process.stderr.write(formatHintInjection(noColor) + '\n'); + stderrLog(formatHintInjection(noColor) + '\n'); messages.push({ role: 'user', content: hint }); } } - process.stderr.write('\nTask complete.\n'); + stderrLog('\nTask complete.\n'); } catch (err) { const jamErr = JamError.fromUnknown(err); await printError(jamErr.message); diff --git a/src/index.ts b/src/index.ts index a595947..c87bc40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,8 @@ program .option('--model ', 'override the model') .option('--base-url ', 'override the provider base URL') .option('--no-color', 'disable color output') - .option('--verbose', 'enable debug logging'); + .option('--verbose', 'enable debug logging') + .option('-q, --quiet', 'suppress non-essential output (spinners, status lines, decorations)'); // ── Helpers ─────────────────────────────────────────────────────────────────── function globalOpts() { @@ -33,6 +34,7 @@ function globalOpts() { baseUrl?: string; color?: boolean; verbose?: boolean; + quiet?: boolean; }>(); } @@ -53,6 +55,7 @@ program model: g.model, baseUrl: g.baseUrl, noColor: g.color === false, + quiet: g.quiet, file: cmdOpts['file'] as string | undefined, json: cmdOpts['json'] as boolean | undefined, system: cmdOpts['system'] as string | undefined, @@ -92,6 +95,7 @@ program model: g.model, baseUrl: g.baseUrl, noColor: g.color === false, + quiet: g.quiet, }); }); diff --git a/src/providers/factory.ts b/src/providers/factory.ts index 8ae6362..c725694 100644 --- a/src/providers/factory.ts +++ b/src/providers/factory.ts @@ -13,8 +13,26 @@ export async function createProvider(profile: Profile): Promise }); } + if (provider === 'openai') { + const { OpenAIAdapter } = await import('./openai.js'); + return new OpenAIAdapter({ + baseUrl: profile.baseUrl, + model: profile.model, + apiKey: profile.apiKey, + }); + } + + if (provider === 'groq') { + const { GroqAdapter } = await import('./groq.js'); + return new GroqAdapter({ + baseUrl: profile.baseUrl, + model: profile.model, + apiKey: profile.apiKey, + }); + } + throw new JamError( - `Unknown provider: "${provider}". Supported providers: ollama`, + `Unknown provider: "${provider}". Supported providers: ollama, openai, groq`, 'CONFIG_INVALID' ); } diff --git a/src/providers/groq.test.ts b/src/providers/groq.test.ts new file mode 100644 index 0000000..a1fd20a --- /dev/null +++ b/src/providers/groq.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { GroqAdapter } from './groq.js'; + +const GROQ_BASE_URL = 'https://api.groq.com/openai'; +const API_KEY = 'test-groq-api-key'; + +function makeSSEStream(events: object[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); +} + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('GroqAdapter', () => { + it('has correct provider name', () => { + const adapter = new GroqAdapter({ apiKey: API_KEY }); + expect(adapter.info.name).toBe('groq'); + expect(adapter.info.supportsStreaming).toBe(true); + }); + + it('streams completions via Groq API', async () => { + server.use( + http.post(`${GROQ_BASE_URL}/v1/chat/completions`, () => { + const stream = makeSSEStream([ + { + id: 'chatcmpl-groq', + choices: [{ delta: { content: 'Fast' }, finish_reason: null }], + }, + { + id: 'chatcmpl-groq', + choices: [{ delta: { content: ' inference' }, finish_reason: null }], + }, + { + id: 'chatcmpl-groq', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 }, + }, + ]); + return new HttpResponse(stream, { + headers: { 'Content-Type': 'text/event-stream' }, + }); + }) + ); + + const adapter = new GroqAdapter({ apiKey: API_KEY }); + const chunks = []; + for await (const chunk of adapter.streamCompletion({ + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk); + } + + const textChunks = chunks.filter((c) => !c.done); + expect(textChunks.map((c) => c.delta).join('')).toBe('Fast inference'); + }); + + it('resolves validateCredentials when API key is valid', async () => { + server.use( + http.get(`${GROQ_BASE_URL}/v1/models`, () => { + return HttpResponse.json({ data: [{ id: 'llama3-8b-8192' }] }); + }) + ); + + const adapter = new GroqAdapter({ apiKey: API_KEY }); + await expect(adapter.validateCredentials()).resolves.toBeUndefined(); + }); + + it('reads API key from GROQ_API_KEY env var', () => { + const saved = process.env['GROQ_API_KEY']; + process.env['GROQ_API_KEY'] = 'env-groq-key'; + try { + const adapter = new GroqAdapter(); + // Access private field through bracket notation to verify key is set + expect(adapter['apiKey']).toBe('env-groq-key'); + } finally { + if (saved !== undefined) { + process.env['GROQ_API_KEY'] = saved; + } else { + delete process.env['GROQ_API_KEY']; + } + } + }); +}); diff --git a/src/providers/groq.ts b/src/providers/groq.ts new file mode 100644 index 0000000..76da156 --- /dev/null +++ b/src/providers/groq.ts @@ -0,0 +1,26 @@ +import { OpenAIAdapter } from './openai.js'; +import type { ProviderInfo } from './base.js'; + +const GROQ_BASE_URL = 'https://api.groq.com/openai'; +const DEFAULT_MODEL = 'llama3-8b-8192'; + +/** + * Groq provider adapter. + * Groq's API is OpenAI-compatible, so this extends OpenAIAdapter with + * Groq-specific defaults (base URL, model, env var for API key). + */ +export class GroqAdapter extends OpenAIAdapter { + override readonly info: ProviderInfo = { + name: 'groq', + supportsStreaming: true, + }; + + constructor(options: { baseUrl?: string; model?: string; apiKey?: string } = {}) { + const apiKey = options.apiKey ?? process.env['GROQ_API_KEY']; + super({ + baseUrl: options.baseUrl ?? GROQ_BASE_URL, + model: options.model ?? DEFAULT_MODEL, + apiKey, + }); + } +} diff --git a/src/providers/ollama.ts b/src/providers/ollama.ts index 6a8c76f..369f0a2 100644 --- a/src/providers/ollama.ts +++ b/src/providers/ollama.ts @@ -58,8 +58,15 @@ export class OllamaAdapter implements ProviderAdapter { signal: AbortSignal.timeout(5000), }); } catch (err) { + const cause = err as NodeJS.ErrnoException; + const isConnRefused = cause?.code === 'ECONNREFUSED' || cause?.code === 'ECONNRESET'; throw new JamError( - `Cannot reach Ollama at ${this.baseUrl}. Start Ollama with: ollama serve`, + isConnRefused + ? `Ollama is not running at ${this.baseUrl}. Start it with: ollama serve\n` + + `If Ollama is not installed, visit: https://ollama.com\n` + + `Run \`jam doctor\` for full diagnostics.` + : `Cannot reach Ollama at ${this.baseUrl}. Start it with: ollama serve\n` + + `Run \`jam doctor\` for full diagnostics.`, 'PROVIDER_UNAVAILABLE', { retryable: false, cause: err } ); @@ -67,7 +74,8 @@ export class OllamaAdapter implements ProviderAdapter { if (!response.ok) { throw new JamError( - `Ollama returned HTTP ${response.status}. Is Ollama running at ${this.baseUrl}?`, + `Ollama returned HTTP ${response.status}. Is Ollama running at ${this.baseUrl}?\n` + + `Run \`jam doctor\` for full diagnostics.`, 'PROVIDER_UNAVAILABLE', { retryable: false } ); @@ -84,15 +92,23 @@ export class OllamaAdapter implements ProviderAdapter { }); if (!response.ok) { throw new JamError( - `Ollama returned HTTP ${response.status}. Is Ollama running at ${this.baseUrl}?`, + `Ollama returned HTTP ${response.status}. Is Ollama running at ${this.baseUrl}?\n` + + `Run \`jam doctor\` for full diagnostics.`, 'PROVIDER_UNAVAILABLE', { retryable: false } ); } } catch (err) { if (JamError.isJamError(err)) throw err; + const cause = err as NodeJS.ErrnoException; + const isConnRefused = cause?.code === 'ECONNREFUSED' || cause?.code === 'ECONNRESET'; throw new JamError( - `Cannot reach Ollama at ${this.baseUrl}. Start Ollama with: ollama serve`, + isConnRefused + ? `Ollama is not running at ${this.baseUrl}. Start it with: ollama serve\n` + + `If Ollama is not installed, visit: https://ollama.com\n` + + `Run \`jam doctor\` for full diagnostics.` + : `Cannot reach Ollama at ${this.baseUrl}. Start it with: ollama serve\n` + + `Run \`jam doctor\` for full diagnostics.`, 'PROVIDER_UNAVAILABLE', { retryable: false, cause: err } ); diff --git a/src/providers/openai.test.ts b/src/providers/openai.test.ts new file mode 100644 index 0000000..7e7d9da --- /dev/null +++ b/src/providers/openai.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { OpenAIAdapter } from './openai.js'; + +const BASE_URL = 'https://api.openai.com'; +const API_KEY = 'test-api-key'; + +function makeSSEStream(events: object[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); +} + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('OpenAIAdapter.streamCompletion', () => { + it('parses SSE stream and yields deltas', async () => { + server.use( + http.post(`${BASE_URL}/v1/chat/completions`, () => { + const stream = makeSSEStream([ + { + id: 'chatcmpl-1', + choices: [{ delta: { content: 'Hello' }, finish_reason: null }], + }, + { + id: 'chatcmpl-1', + choices: [{ delta: { content: ' world' }, finish_reason: null }], + }, + { + id: 'chatcmpl-1', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + ]); + return new HttpResponse(stream, { + headers: { 'Content-Type': 'text/event-stream' }, + }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, model: 'gpt-4o-mini', apiKey: API_KEY }); + const chunks = []; + for await (const chunk of adapter.streamCompletion({ + messages: [{ role: 'user', content: 'Hello' }], + })) { + chunks.push(chunk); + } + + const textChunks = chunks.filter((c) => !c.done); + expect(textChunks.map((c) => c.delta).join('')).toBe('Hello world'); + + const doneChunk = chunks.find((c) => c.done); + expect(doneChunk?.usage?.promptTokens).toBe(10); + expect(doneChunk?.usage?.completionTokens).toBe(5); + expect(doneChunk?.usage?.totalTokens).toBe(15); + }); + + it('throws PROVIDER_AUTH_FAILED on 401', async () => { + server.use( + http.post(`${BASE_URL}/v1/chat/completions`, () => { + return new HttpResponse('Unauthorized', { status: 401 }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, apiKey: API_KEY }); + const iter = adapter.streamCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + + await expect(async () => { + for await (const _chunk of iter) { /* consume */ } + }).rejects.toMatchObject({ code: 'PROVIDER_AUTH_FAILED' }); + }); + + it('throws PROVIDER_MODEL_NOT_FOUND on 404', async () => { + server.use( + http.post(`${BASE_URL}/v1/chat/completions`, () => { + return new HttpResponse('model not found', { status: 404 }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, model: 'nonexistent', apiKey: API_KEY }); + const iter = adapter.streamCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + + await expect(async () => { + for await (const _chunk of iter) { /* consume */ } + }).rejects.toMatchObject({ code: 'PROVIDER_MODEL_NOT_FOUND' }); + }); + + it('throws PROVIDER_RATE_LIMITED on 429', async () => { + server.use( + http.post(`${BASE_URL}/v1/chat/completions`, () => { + return new HttpResponse('rate limited', { status: 429 }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, apiKey: API_KEY }); + const iter = adapter.streamCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + + await expect(async () => { + for await (const _chunk of iter) { /* consume */ } + }).rejects.toMatchObject({ code: 'PROVIDER_RATE_LIMITED' }); + }); + + it('throws PROVIDER_STREAM_ERROR on non-OK response', async () => { + server.use( + http.post(`${BASE_URL}/v1/chat/completions`, () => { + return new HttpResponse('internal error', { status: 500 }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, apiKey: API_KEY }); + const iter = adapter.streamCompletion({ messages: [{ role: 'user', content: 'hi' }] }); + + await expect(async () => { + for await (const _chunk of iter) { /* consume */ } + }).rejects.toMatchObject({ code: 'PROVIDER_STREAM_ERROR' }); + }); + + it('includes system prompt when provided', async () => { + let capturedBody: unknown; + + server.use( + http.post(`${BASE_URL}/v1/chat/completions`, async ({ request }) => { + capturedBody = await request.json(); + const stream = makeSSEStream([ + { + id: 'chatcmpl-1', + choices: [{ delta: { content: 'ok' }, finish_reason: null }], + }, + { + id: 'chatcmpl-1', + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }, + ]); + return new HttpResponse(stream, { + headers: { 'Content-Type': 'text/event-stream' }, + }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, model: 'gpt-4o-mini', apiKey: API_KEY }); + for await (const _chunk of adapter.streamCompletion({ + messages: [{ role: 'user', content: 'hello' }], + systemPrompt: 'You are helpful.', + })) { /* consume */ } + + const body = capturedBody as { messages: Array<{ role: string; content: string }> }; + expect(body.messages[0]?.role).toBe('system'); + expect(body.messages[0]?.content).toBe('You are helpful.'); + }); +}); + +describe('OpenAIAdapter.validateCredentials', () => { + it('resolves when API key is valid', async () => { + server.use( + http.get(`${BASE_URL}/v1/models`, () => { + return HttpResponse.json({ data: [{ id: 'gpt-4o-mini' }] }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, apiKey: API_KEY }); + await expect(adapter.validateCredentials()).resolves.toBeUndefined(); + }); + + it('throws PROVIDER_AUTH_FAILED when API key is invalid', async () => { + server.use( + http.get(`${BASE_URL}/v1/models`, () => { + return new HttpResponse('Unauthorized', { status: 401 }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, apiKey: 'bad-key' }); + await expect(adapter.validateCredentials()).rejects.toMatchObject({ + code: 'PROVIDER_AUTH_FAILED', + }); + }); +}); + +describe('OpenAIAdapter.listModels', () => { + it('returns sorted model ids', async () => { + server.use( + http.get(`${BASE_URL}/v1/models`, () => { + return HttpResponse.json({ + data: [{ id: 'gpt-4o' }, { id: 'gpt-3.5-turbo' }, { id: 'gpt-4o-mini' }], + }); + }) + ); + + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL, apiKey: API_KEY }); + const models = await adapter.listModels(); + expect(models).toEqual(['gpt-3.5-turbo', 'gpt-4o', 'gpt-4o-mini']); + }); +}); + +describe('OpenAIAdapter — missing API key', () => { + it('throws PROVIDER_AUTH_FAILED when no API key is available', () => { + // Ensure env var is not set + const saved = process.env['OPENAI_API_KEY']; + delete process.env['OPENAI_API_KEY']; + + try { + const adapter = new OpenAIAdapter({ baseUrl: BASE_URL }); + expect(() => adapter['authHeaders']()).toThrow(); + } finally { + if (saved !== undefined) process.env['OPENAI_API_KEY'] = saved; + } + }); +}); diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..89e1a42 --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,359 @@ +import type { + ProviderAdapter, + ProviderInfo, + CompletionRequest, + StreamChunk, + Message, + ToolDefinition, + ToolCall, + ChatWithToolsResponse, +} from './base.js'; +import { JamError } from '../utils/errors.js'; + +const DEFAULT_BASE_URL = 'https://api.openai.com'; +const DEFAULT_MODEL = 'gpt-4o-mini'; + +// OpenAI-private types — do not export +interface OpenAIChatMessage { + role: string; + content: string | null; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { name: string; arguments: string }; + }>; +} + +interface OpenAIStreamChunk { + id: string; + choices: Array<{ + delta: { content?: string | null; role?: string }; + finish_reason: string | null; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +interface OpenAIChatResponse { + id: string; + choices: Array<{ + message: OpenAIChatMessage; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +interface OpenAIModelsResponse { + data: Array<{ id: string }>; +} + +function toOpenAIMessages(messages: Message[]): OpenAIChatMessage[] { + return messages.map((m) => ({ role: m.role, content: m.content })); +} + +function getApiKey(profileApiKey?: string): string | undefined { + return profileApiKey ?? process.env['OPENAI_API_KEY']; +} + +export class OpenAIAdapter implements ProviderAdapter { + readonly info: ProviderInfo = { + name: 'openai', + supportsStreaming: true, + }; + + private readonly baseUrl: string; + private readonly model: string; + private readonly apiKey: string | undefined; + + constructor(options: { baseUrl?: string; model?: string; apiKey?: string } = {}) { + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); + this.model = options.model ?? DEFAULT_MODEL; + this.apiKey = getApiKey(options.apiKey); + } + + private authHeaders(): Record { + if (!this.apiKey) { + throw new JamError( + 'No OpenAI API key found. Set OPENAI_API_KEY environment variable or configure apiKey in your profile.', + 'PROVIDER_AUTH_FAILED', + { retryable: false } + ); + } + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }; + } + + async listModels(): Promise { + const headers = this.authHeaders(); + let response: Response; + try { + response = await fetch(`${this.baseUrl}/v1/models`, { + headers, + signal: AbortSignal.timeout(10_000), + }); + } catch (err) { + throw new JamError( + `Cannot reach OpenAI at ${this.baseUrl}. Check your network connection.`, + 'PROVIDER_UNAVAILABLE', + { retryable: false, cause: err } + ); + } + + if (response.status === 401) { + throw new JamError( + 'OpenAI API key is invalid or expired. Set a valid OPENAI_API_KEY.', + 'PROVIDER_AUTH_FAILED', + { retryable: false, statusCode: response.status } + ); + } + + if (!response.ok) { + throw new JamError( + `OpenAI returned HTTP ${response.status} from /v1/models.`, + 'PROVIDER_UNAVAILABLE', + { retryable: false, statusCode: response.status } + ); + } + + const data = (await response.json()) as OpenAIModelsResponse; + return (data.data ?? []).map((m) => m.id).sort(); + } + + async validateCredentials(): Promise { + // A lightweight check: list models (requires a valid API key) + await this.listModels(); + } + + async *streamCompletion(request: CompletionRequest): AsyncIterable { + const messages: OpenAIChatMessage[] = []; + if (request.systemPrompt) { + messages.push({ role: 'system', content: request.systemPrompt }); + } + messages.push(...toOpenAIMessages(request.messages)); + + const body = { + model: request.model ?? this.model, + messages, + stream: true, + stream_options: { include_usage: true }, + ...(request.temperature !== undefined ? { temperature: request.temperature } : {}), + ...(request.maxTokens !== undefined ? { max_tokens: request.maxTokens } : {}), + }; + + const headers = this.authHeaders(); + let response: Response; + try { + response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }); + } catch (err) { + throw new JamError( + `Failed to connect to OpenAI at ${this.baseUrl}`, + 'PROVIDER_UNAVAILABLE', + { retryable: true, cause: err } + ); + } + + if (response.status === 401) { + throw new JamError( + 'OpenAI API key is invalid or expired. Set a valid OPENAI_API_KEY.', + 'PROVIDER_AUTH_FAILED', + { retryable: false, statusCode: response.status } + ); + } + + if (response.status === 404) { + throw new JamError( + `Model not found: ${request.model ?? this.model}. Check available models with: jam models list`, + 'PROVIDER_MODEL_NOT_FOUND', + { retryable: false, statusCode: response.status } + ); + } + + if (response.status === 429) { + throw new JamError( + 'OpenAI rate limit reached. Try again shortly.', + 'PROVIDER_RATE_LIMITED', + { retryable: true, statusCode: response.status } + ); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new JamError( + `OpenAI error ${response.status}: ${errorText}`, + 'PROVIDER_STREAM_ERROR', + { retryable: false, statusCode: response.status } + ); + } + + if (!response.body) { + throw new JamError('OpenAI returned empty response body', 'PROVIDER_STREAM_ERROR', { + retryable: true, + }); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + for await (const rawChunk of response.body) { + buffer += decoder.decode(rawChunk as Uint8Array, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (!trimmed.startsWith('data: ')) continue; + + let parsed: OpenAIStreamChunk; + try { + parsed = JSON.parse(trimmed.slice(6)) as OpenAIStreamChunk; + } catch { + continue; + } + + const choice = parsed.choices?.[0]; + if (choice) { + const delta = choice.delta.content ?? ''; + const done = choice.finish_reason !== null; + if (done) { + const usage = parsed.usage; + yield { + delta: '', + done: true, + usage: usage + ? { + promptTokens: usage.prompt_tokens, + completionTokens: usage.completion_tokens, + totalTokens: usage.total_tokens, + } + : undefined, + }; + } else if (delta) { + yield { delta, done: false }; + } + } else if (parsed.usage) { + // usage-only chunk (stream_options.include_usage) + yield { + delta: '', + done: true, + usage: { + promptTokens: parsed.usage.prompt_tokens, + completionTokens: parsed.usage.completion_tokens, + totalTokens: parsed.usage.total_tokens, + }, + }; + } + } + } + } + + async chatWithTools( + messages: Message[], + tools: ToolDefinition[], + options: Pick = {} + ): Promise { + const openAIMessages: OpenAIChatMessage[] = []; + if (options.systemPrompt) { + openAIMessages.push({ role: 'system', content: options.systemPrompt }); + } + openAIMessages.push(...toOpenAIMessages(messages)); + + const body = { + model: options.model ?? this.model, + messages: openAIMessages, + tools: tools.map((t) => ({ + type: 'function' as const, + function: { name: t.name, description: t.description, parameters: t.parameters }, + })), + ...(options.temperature !== undefined ? { temperature: options.temperature } : {}), + ...(options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}), + }; + + const headers = this.authHeaders(); + let response: Response; + try { + response = await fetch(`${this.baseUrl}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }); + } catch (err) { + throw new JamError( + `Failed to connect to OpenAI at ${this.baseUrl}`, + 'PROVIDER_UNAVAILABLE', + { retryable: true, cause: err } + ); + } + + if (response.status === 401) { + throw new JamError( + 'OpenAI API key is invalid or expired. Set a valid OPENAI_API_KEY.', + 'PROVIDER_AUTH_FAILED', + { retryable: false, statusCode: response.status } + ); + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new JamError( + `OpenAI error ${response.status}: ${errorText}`, + 'PROVIDER_STREAM_ERROR', + { retryable: false, statusCode: response.status } + ); + } + + const data = (await response.json()) as OpenAIChatResponse; + const msg = data.choices[0]?.message; + if (!msg) { + throw new JamError('OpenAI returned no choices.', 'PROVIDER_STREAM_ERROR', { + retryable: false, + }); + } + + const toolCalls: ToolCall[] | undefined = msg.tool_calls?.map((tc) => { + let parsedArgs: Record; + try { + parsedArgs = JSON.parse(tc.function.arguments) as Record; + } catch { + throw new JamError( + `Failed to parse tool call arguments from OpenAI response for tool "${tc.function.name}"`, + 'PROVIDER_STREAM_ERROR', + { retryable: false } + ); + } + return { + id: tc.id, + name: tc.function.name, + arguments: parsedArgs, + }; + }); + + const usage = data.usage; + return { + content: msg.content ?? null, + toolCalls: toolCalls?.length ? toolCalls : undefined, + usage: usage + ? { + promptTokens: usage.prompt_tokens, + completionTokens: usage.completion_tokens, + totalTokens: usage.total_tokens, + } + : undefined, + }; + } +} diff --git a/src/storage/history.test.ts b/src/storage/history.test.ts index efaf20f..07e4369 100644 --- a/src/storage/history.test.ts +++ b/src/storage/history.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtemp } from 'node:fs/promises'; +import { mkdtemp, access } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { rmSync } from 'node:fs'; @@ -151,3 +151,79 @@ describe('history storage', () => { expect(d2!.messages[0]!.content).toBe('for s2'); }); }); + +// --------------------------------------------------------------------------- +// Platform-specific path tests (Issue #6) +// --------------------------------------------------------------------------- + +describe('getSessionDir() platform paths', () => { + const originalPlatform = process.platform; + const originalXdg = process.env['XDG_DATA_HOME']; + const originalAppData = process.env['APPDATA']; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); + if (originalXdg === undefined) { + delete process.env['XDG_DATA_HOME']; + } else { + process.env['XDG_DATA_HOME'] = originalXdg; + } + if (originalAppData === undefined) { + delete process.env['APPDATA']; + } else { + process.env['APPDATA'] = originalAppData; + } + rmSync(fakeHome, { recursive: true, force: true }); + }); + + it('uses Library/Application Support on macOS (darwin)', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', writable: true }); + fakeHome = await mkdtemp(join(tmpdir(), 'jam-darwin-')); + + const session = await createSession('mac-test', '/workspace'); + const expectedPath = join(fakeHome, 'Library', 'Application Support', 'jam', 'sessions', `${session.id}.json`); + await expect(access(expectedPath)).resolves.toBeUndefined(); + }); + + it('uses XDG_DATA_HOME on Linux when set', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }); + fakeHome = await mkdtemp(join(tmpdir(), 'jam-linux-')); + const xdgDir = join(fakeHome, 'xdg'); + process.env['XDG_DATA_HOME'] = xdgDir; + + const session = await createSession('linux-xdg-test', '/workspace'); + const expectedPath = join(xdgDir, 'jam', 'sessions', `${session.id}.json`); + await expect(access(expectedPath)).resolves.toBeUndefined(); + }); + + it('falls back to ~/.local/share on Linux when XDG_DATA_HOME is unset', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }); + delete process.env['XDG_DATA_HOME']; + fakeHome = await mkdtemp(join(tmpdir(), 'jam-linux-fallback-')); + + const session = await createSession('linux-fallback-test', '/workspace'); + const expectedPath = join(fakeHome, '.local', 'share', 'jam', 'sessions', `${session.id}.json`); + await expect(access(expectedPath)).resolves.toBeUndefined(); + }); + + it('uses APPDATA on Windows (win32) when set', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }); + fakeHome = await mkdtemp(join(tmpdir(), 'jam-win32-')); + const appDataDir = join(fakeHome, 'AppData', 'Roaming'); + process.env['APPDATA'] = appDataDir; + + const session = await createSession('windows-test', '/workspace'); + const expectedPath = join(appDataDir, 'jam', 'sessions', `${session.id}.json`); + await expect(access(expectedPath)).resolves.toBeUndefined(); + }); + + it('falls back to homedir on Windows when APPDATA is unset', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }); + delete process.env['APPDATA']; + fakeHome = await mkdtemp(join(tmpdir(), 'jam-win32-fallback-')); + + const session = await createSession('windows-fallback-test', '/workspace'); + const expectedPath = join(fakeHome, 'jam', 'sessions', `${session.id}.json`); + await expect(access(expectedPath)).resolves.toBeUndefined(); + }); +}); diff --git a/src/tools/all-tools.ts b/src/tools/all-tools.ts index 8114e9f..d6403c1 100644 --- a/src/tools/all-tools.ts +++ b/src/tools/all-tools.ts @@ -53,6 +53,22 @@ const WRITE_TOOL_SCHEMAS: ToolDefinition[] = [ required: ['patch'], }, }, + { + name: 'run_command', + description: + 'Execute a shell command and return its stdout and stderr. ' + + 'Use for running tests, builds, linters, or other safe commands. ' + + 'Dangerous commands (rm -rf, sudo, etc.) are blocked.', + parameters: { + type: 'object', + properties: { + command: { type: 'string', description: 'The executable to run (e.g. "npm", "git", "python")' }, + args: { type: 'string', description: 'Space-separated arguments (e.g. "test --run")' }, + timeout: { type: 'number', description: 'Timeout in seconds. Default is 30.' }, + }, + required: ['command'], + }, + }, ]; /** All tool schemas for the run command (read + write). */ diff --git a/src/tools/apply_patch.ts b/src/tools/apply_patch.ts index fc815eb..82d325b 100644 --- a/src/tools/apply_patch.ts +++ b/src/tools/apply_patch.ts @@ -57,7 +57,7 @@ export const applyPatchTool: ToolDefinition = { // Apply the patch let stdout: string; try { - stdout = await runCommand('git', ['apply', tempFile], ctx.workspaceRoot); + ({ stdout } = await runCommand('git', ['apply', tempFile], ctx.workspaceRoot)); } catch (err: unknown) { const detail = err instanceof Error ? err.message : String(err); throw new JamError( diff --git a/src/tools/git_diff.ts b/src/tools/git_diff.ts index 53e3cdf..246c890 100644 --- a/src/tools/git_diff.ts +++ b/src/tools/git_diff.ts @@ -37,7 +37,7 @@ export const gitDiffTool: ToolDefinition = { let stdout: string; try { - stdout = await runCommand('git', gitArgs, ctx.workspaceRoot); + ({ stdout } = await runCommand('git', gitArgs, ctx.workspaceRoot)); } catch (err: unknown) { throw new JamError( 'git diff failed. Is this a git repository?', diff --git a/src/tools/git_status.ts b/src/tools/git_status.ts index b818747..f6eb481 100644 --- a/src/tools/git_status.ts +++ b/src/tools/git_status.ts @@ -15,7 +15,7 @@ export const gitStatusTool: ToolDefinition = { async execute(_args: Record, ctx: ToolContext): Promise { let stdout: string; try { - stdout = await runCommand('git', ['status', '--short'], ctx.workspaceRoot); + ({ stdout } = await runCommand('git', ['status', '--short'], ctx.workspaceRoot)); } catch (err: unknown) { throw new JamError( 'git status failed. Is this a git repository?', diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 2a8e7d2..21a00f5 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -10,6 +10,7 @@ import { gitDiffTool } from './git_diff.js'; import { gitStatusTool } from './git_status.js'; import { applyPatchTool } from './apply_patch.js'; import { writeFileTool } from './write_file.js'; +import { runCommandTool } from './run_command.js'; export class ToolRegistry { private readonly tools = new Map(); @@ -100,5 +101,6 @@ export function createDefaultRegistry(): ToolRegistry { registry.register(gitStatusTool); registry.register(applyPatchTool); registry.register(writeFileTool); + registry.register(runCommandTool); return registry; } diff --git a/src/tools/run_command.test.ts b/src/tools/run_command.test.ts new file mode 100644 index 0000000..acc9667 --- /dev/null +++ b/src/tools/run_command.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { runCommandTool } from './run_command.js'; +import { JamError } from '../utils/errors.js'; +import type { ToolContext } from './types.js'; + +const ctx: ToolContext = { workspaceRoot: process.cwd(), cwd: process.cwd() }; + +describe('runCommandTool', () => { + it('is a write tool (readonly: false)', () => { + expect(runCommandTool.readonly).toBe(false); + }); + + it('captures stdout from a successful command', async () => { + const result = await runCommandTool.execute({ command: 'echo', args: 'hello world' }, ctx); + expect(result.error).toBeUndefined(); + expect(result.output).toContain('hello world'); + }); + + it('captures stderr and includes it in output', async () => { + // process.stderr.write('err') has no spaces so it stays as a single arg after split + const result = await runCommandTool.execute( + { command: 'node', args: "-e process.stderr.write('err')" }, + ctx + ); + expect(result.output).toContain('[stderr]'); + }); + + it('returns error output for non-zero exit code', async () => { + // Without shell quoting: node receives ['-e', 'process.exit(1)'] directly + const result = await runCommandTool.execute({ command: 'node', args: '-e process.exit(1)' }, ctx); + expect(result.error).toBeTruthy(); + }); + + it('throws INPUT_MISSING when command is empty', async () => { + await expect( + runCommandTool.execute({ command: '' }, ctx) + ).rejects.toSatisfy((err: unknown) => { + return JamError.isJamError(err) && err.code === 'INPUT_MISSING'; + }); + }); + + it('throws INPUT_MISSING when command is missing', async () => { + await expect( + runCommandTool.execute({}, ctx) + ).rejects.toSatisfy((err: unknown) => { + return JamError.isJamError(err) && err.code === 'INPUT_MISSING'; + }); + }); + + it('blocks dangerous commands: rm -rf', async () => { + await expect( + runCommandTool.execute({ command: 'rm', args: '-rf /' }, ctx) + ).rejects.toSatisfy((err: unknown) => { + return JamError.isJamError(err) && err.code === 'TOOL_DENIED'; + }); + }); + + it('blocks sudo commands', async () => { + await expect( + runCommandTool.execute({ command: 'sudo', args: 'apt-get install vim' }, ctx) + ).rejects.toSatisfy((err: unknown) => { + return JamError.isJamError(err) && err.code === 'TOOL_DENIED'; + }); + }); + + it('blocks shutdown commands', async () => { + await expect( + runCommandTool.execute({ command: 'shutdown', args: '-h now' }, ctx) + ).rejects.toSatisfy((err: unknown) => { + return JamError.isJamError(err) && err.code === 'TOOL_DENIED'; + }); + }); + + it('returns (no output) when command produces no output', async () => { + const result = await runCommandTool.execute({ command: 'true' }, ctx); + expect(result.output).toBe('(no output)'); + }); + + it('passes timeout argument (uses seconds)', async () => { + // A command that finishes quickly — just verify it works with a custom timeout + const result = await runCommandTool.execute({ command: 'echo', args: 'quick', timeout: 5 }, ctx); + expect(result.output).toContain('quick'); + }); +}); diff --git a/src/tools/run_command.ts b/src/tools/run_command.ts index 78b1b75..6d98e05 100644 --- a/src/tools/run_command.ts +++ b/src/tools/run_command.ts @@ -1,15 +1,31 @@ import { spawn } from 'node:child_process'; +import { JamError } from '../utils/errors.js'; +import type { ToolDefinition, ToolContext, ToolResult } from './types.js'; + +// Patterns that are never allowed regardless of policy +const DANGEROUS_PATTERNS: RegExp[] = [ + /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f\b/, // rm -rf, rm -fr, etc. + /\brm\s+-[a-zA-Z]*f[a-zA-Z]*r\b/, + /\bsudo\b/, + /\bsu\s+-/, + /\bmkfs\b/, + /\bdd\s+.*of=\/dev\//, + />\s*\/dev\/(s|h|v)d[a-z]/, // overwriting block devices + /\bchmod\s+777\s+\//, + /\bshutdown\b/, + /\breboot\b/, +]; /** - * Spawns a child process and returns stdout as a string. - * Rejects with an Error containing stderr if the process exits with a non-zero code. + * Spawns a child process and returns { stdout, stderr } as strings. + * Rejects with an Error if the process exits with a non-zero code or times out. */ export function runCommand( command: string, args: string[], cwd: string, timeoutMs = 30_000 -): Promise { +): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -32,7 +48,7 @@ export function runCommand( child.on('close', (code) => { clearTimeout(timer); if (code === 0) { - resolve(stdout); + resolve({ stdout, stderr }); } else { const detail = stderr.trim() || stdout.trim(); reject( @@ -49,3 +65,91 @@ export function runCommand( }); }); } + +/** Check a full command string against dangerous patterns. */ +function isDangerous(fullCommand: string): boolean { + return DANGEROUS_PATTERNS.some((pattern) => pattern.test(fullCommand)); +} + +export const runCommandTool: ToolDefinition = { + name: 'run_command', + description: + 'Execute a shell command and return its stdout and stderr. ' + + 'Use for running tests, builds, linters, or other safe commands. ' + + 'Dangerous commands (rm -rf, sudo, etc.) are blocked. ' + + 'Note: arguments are split on whitespace; quoted arguments with spaces are not supported.', + readonly: false, + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'The executable or shell command to run (e.g. "npm", "git", "python").', + }, + args: { + type: 'string', + description: 'Space-separated arguments to pass to the command (e.g. "test --run").', + optional: true, + }, + timeout: { + type: 'number', + description: 'Timeout in seconds. Default is 30.', + optional: true, + }, + }, + required: ['command'], + }, + + async execute(args: Record, ctx: ToolContext): Promise { + const command = args['command']; + if (typeof command !== 'string' || command.trim() === '') { + throw new JamError('Argument "command" must be a non-empty string.', 'INPUT_MISSING'); + } + + const rawArgs = args['args']; + const cmdArgs: string[] = + typeof rawArgs === 'string' && rawArgs.trim() !== '' + ? rawArgs.trim().split(/\s+/) + : []; + + const timeoutArg = args['timeout']; + const timeoutMs = + typeof timeoutArg === 'number' && timeoutArg > 0 ? timeoutArg * 1000 : 30_000; + + const fullCommand = [command, ...cmdArgs].join(' '); + + if (isDangerous(fullCommand)) { + throw new JamError( + `Command rejected: "${fullCommand}" matches a dangerous pattern and cannot be executed.`, + 'TOOL_DENIED' + ); + } + + let result: { stdout: string; stderr: string }; + try { + result = await runCommand(command.trim(), cmdArgs, ctx.workspaceRoot, timeoutMs); + } catch (err: unknown) { + if (err instanceof Error) { + return { + output: err.message, + error: err.message, + metadata: { command: fullCommand, exitCode: 1 }, + }; + } + throw new JamError(`run_command failed: ${String(err)}`, 'TOOL_EXEC_ERROR', { cause: err }); + } + + const output = [ + result.stdout.trim() ? result.stdout : '', + result.stderr.trim() ? `[stderr]\n${result.stderr}` : '', + ] + .filter(Boolean) + .join('\n') + .trim(); + + return { + output: output || '(no output)', + metadata: { command: fullCommand }, + }; + }, +};