diff --git a/src/commands/ai.test.ts b/src/commands/ai.test.ts index fd18a83..4f0f14c 100644 --- a/src/commands/ai.test.ts +++ b/src/commands/ai.test.ts @@ -79,6 +79,7 @@ describe('askAi', () => { stack: null, doctor: null, recentHistory: [], + recentShellHistory: [], }; it('requires ai assistant to be enabled in config', async () => { @@ -143,6 +144,7 @@ describe('askAi', () => { thinkingLevel: 'high', includeThoughts: true, }, + useSearchGrounding: true, }, }, }), @@ -250,6 +252,43 @@ describe('askAi', () => { ).rejects.toThrow('boom'); }); + it('falls back gracefully when browsing options are unsupported', async () => { + await writeConfig({ aiAssistantEnabled: true }, dir); + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; + + const streamText = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('unsupported provider option useSearchGrounding'); + }) + .mockReturnValueOnce({ + fullStream: streamFrom(['fallback answer']), + }); + const googleModel = vi.fn().mockReturnValue('google-model'); + const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + const createGateway = vi.fn(); + const collectAiContext = vi.fn().mockResolvedValue(fakeContext); + const { createBashTool } = createBashToolMock(); + const output = createOutputCapture(); + + const result = await askAi('Explain this stack', dir, { + output: output.stream, + deps: { + streamText, + createGoogleGenerativeAI, + createGateway, + collectAiContext, + createBashTool, + }, + }); + + expect(streamText).toHaveBeenCalledTimes(2); + expect(output.writes.join('')).toContain('Web browsing is unavailable'); + expect(result.webBrowsingRequested).toBe(true); + expect(result.webBrowsingUsed).toBe(false); + }); + it('requires at least one AI key environment variable', async () => { await writeConfig({ aiAssistantEnabled: true }, dir); delete process.env.DUBSTACK_GEMINI_API_KEY; diff --git a/src/commands/ai.ts b/src/commands/ai.ts index d77331c..c560c8c 100644 --- a/src/commands/ai.ts +++ b/src/commands/ai.ts @@ -32,6 +32,8 @@ interface AskAiOptions { interface AskAiResult { provider: 'google' | 'gateway'; modelId: string; + webBrowsingRequested: boolean; + webBrowsingUsed: boolean; } const DEFAULT_DEPS: AskAiDependencies = { @@ -49,7 +51,7 @@ const THINKING_PROVIDER_OPTIONS = { includeThoughts: true, }, }, -}; +} as const; const SPINNER_FRAMES = ['-', '\\', '|', '/'] as const; @@ -82,50 +84,38 @@ export async function askAi( 'Safety: use bash only when command output is needed. Do not run destructive commands (for example, rm -rf, git reset --hard, git clean -fd), even if the user explicitly asks. This sandbox only allows read-only command families. If the user insists on blocked actions, explain the command is blocked here and provide a manual command they can run themselves at their own risk.', }); - const result = deps.streamText({ - model: resolved.model, - system: buildAiSystemPrompt(), - prompt: contextPrompt, - stopWhen: stepCountIs(6), - tools: { - bash: bashToolkit.tools.bash, - }, - providerOptions: THINKING_PROVIDER_OPTIONS, - }); - - const thinkingRenderer = createThinkingRenderer(output); + const webBrowsingRequested = config.ai.webBrowsing.mode === 'model-native'; + let webBrowsingUsed = webBrowsingRequested; let wroteOutput = false; - for await (const part of result.fullStream) { - switch (part.type) { - case 'reasoning-start': { - thinkingRenderer.start(); - break; - } - case 'reasoning-delta': { - thinkingRenderer.update(part.text); - break; - } - case 'reasoning-end': { - thinkingRenderer.stop(); - break; - } - case 'text-delta': { - thinkingRenderer.pauseForText(); - output.write(part.text); - wroteOutput = true; - break; - } - case 'error': { - throw part.error instanceof Error - ? part.error - : new DubError('AI assistant stream failed unexpectedly.'); - } - default: { - break; - } + const runStream = async (withWebBrowsing: boolean): Promise => { + const result = deps.streamText({ + model: resolved.model, + system: buildAiSystemPrompt(), + prompt: contextPrompt, + stopWhen: stepCountIs(6), + tools: { + bash: bashToolkit.tools.bash, + }, + providerOptions: buildProviderOptions({ withWebBrowsing }) as never, + }); + return renderStream(result, output); + }; + + try { + wroteOutput = await runStream(webBrowsingRequested); + } catch (error) { + if (!isBrowsingUnsupportedError(error)) { + throw error; + } + if (config.ai.webBrowsing.fallback !== 'graceful') { + throw error; } + webBrowsingUsed = false; + output.write( + '[note] Web browsing is unavailable for this provider/model right now. Continuing with local context and model knowledge.\n', + ); + wroteOutput = await runStream(false); } - thinkingRenderer.stop(); if (wroteOutput) { output.write('\n'); @@ -134,9 +124,81 @@ export async function askAi( return { provider: resolved.provider, modelId: resolved.modelId, + webBrowsingRequested, + webBrowsingUsed, }; } +function buildProviderOptions(options: { + withWebBrowsing: boolean; +}): Record { + const googleOptions: Record = { + ...(THINKING_PROVIDER_OPTIONS.google as unknown as Record), + }; + if (options.withWebBrowsing) { + googleOptions.useSearchGrounding = true; + } + return { google: googleOptions }; +} + +async function renderStream( + result: { + fullStream: AsyncIterable<{ + type: string; + text?: string; + error?: unknown; + }>; + }, + output: WritableLike, +): Promise { + const thinkingRenderer = createThinkingRenderer(output); + let wroteOutput = false; + try { + for await (const part of result.fullStream) { + switch (part.type) { + case 'reasoning-start': { + thinkingRenderer.start(); + break; + } + case 'reasoning-delta': { + thinkingRenderer.update(part.text ?? ''); + break; + } + case 'reasoning-end': { + thinkingRenderer.stop(); + break; + } + case 'text-delta': { + thinkingRenderer.pauseForText(); + output.write(part.text ?? ''); + wroteOutput = true; + break; + } + case 'error': { + throw part.error instanceof Error + ? part.error + : new DubError('AI assistant stream failed unexpectedly.'); + } + default: { + break; + } + } + } + } finally { + thinkingRenderer.stop(); + } + return wroteOutput; +} + +function isBrowsingUnsupportedError(error: unknown): boolean { + const text = error instanceof Error ? error.message : String(error); + const normalized = text.toLowerCase(); + return ( + normalized.includes('unsupported') && + (normalized.includes('grounding') || normalized.includes('brows')) + ); +} + function resolveModel(deps: AskAiDependencies): { provider: 'google' | 'gateway'; model: LanguageModel; diff --git a/src/index.ts b/src/index.ts index c9d9459..1cf2dd2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,13 @@ import { track } from './commands/track'; import { trunk } from './commands/trunk'; import { undo } from './commands/undo'; import { untrack } from './commands/untrack'; +import { + collectKnownTopLevelCommands, + preprocessCliArgs, + promptTypoResolution, + type ShortcutMetadata, +} from './lib/ai-shortcut'; +import { readConfig } from './lib/config'; import { DubError } from './lib/errors'; import { getCurrentBranch } from './lib/git'; import { @@ -69,7 +76,14 @@ const program = new Command(); program .name('dub') .description('Manage stacked diffs (dependent git branches) with ease') - .version(version); + .version(version) + .addHelpText( + 'after', + ` +Examples: + $ dub "what changed in this stack?" Ask AI directly + $ dub --ai "summarize terminal work" Force AI shortcut mode`, + ); program .command('init') @@ -912,14 +926,21 @@ program program .command('ai') - .description('Use DubStack AI assistant utilities') + .description( + 'Use DubStack AI assistant utilities (or shortcut with: dub PROMPT)', + ) .addCommand( new Command('ask') .argument('', 'Prompt text to send to the AI assistant') - .description('Ask DubStack AI assistant a question') + .description('Ask DubStack AI assistant a question (explicit mode)') .action(async (promptParts: string[]) => { const { askAi } = await import('./commands/ai'); - await askAi(promptParts.join(' '), process.cwd()); + if (!invocationMetadata.invocationMode) { + invocationMetadata.invocationMode = 'explicit-ai'; + } + const result = await askAi(promptParts.join(' '), process.cwd()); + invocationMetadata.webBrowsingRequested = result.webBrowsingRequested; + invocationMetadata.webBrowsingUsed = result.webBrowsingUsed; }), ) .addCommand( @@ -1120,6 +1141,11 @@ interface HistoryCaptureState { const MAX_HISTORY_OUTPUT_LINES = 120; const MAX_HISTORY_OUTPUT_LINE_LENGTH = 500; let historyCapture: HistoryCaptureState | null = null; +let historyArgsForCapture: string[] | null = null; +let invocationMetadata: ShortcutMetadata & { + webBrowsingRequested?: boolean; + webBrowsingUsed?: boolean; +} = {}; program.hook('preAction', () => { beginHistoryCapture(); @@ -1131,6 +1157,27 @@ program.hook('postAction', async () => { async function main() { try { + const rawArgs = process.argv.slice(2); + historyArgsForCapture = rawArgs; + const knownCommands = collectKnownTopLevelCommands(program.commands); + const config = await readConfig(process.cwd()).catch(() => null); + const shortcutEnabled = config?.ai.shortcutFallback.enabled ?? true; + const preprocessed = + shortcutEnabled || rawArgs[0] === '--ai' + ? await preprocessCliArgs( + rawArgs, + knownCommands, + Boolean(process.stdin.isTTY && process.stdout.isTTY), + promptTypoResolution, + ) + : { finalArgs: rawArgs, metadata: {} }; + invocationMetadata = { ...preprocessed.metadata }; + process.argv = [ + process.argv[0], + process.argv[1], + ...preprocessed.finalArgs, + ]; + await program.parseAsync(process.argv); } catch (error) { if (error instanceof DubError) { @@ -1150,7 +1197,8 @@ async function main() { function beginHistoryCapture(): void { if (historyCapture) return; - const sanitizedArgs = sanitizeCommandArgs(process.argv.slice(2)); + const captureArgs = historyArgsForCapture ?? process.argv.slice(2); + const sanitizedArgs = sanitizeCommandArgs(captureArgs); if (sanitizedArgs.length === 0) return; const output: string[] = []; @@ -1243,6 +1291,10 @@ async function finalizeHistoryCapture( durationMs: Date.now() - capture.startedAt, output: capture.output, errorMessage, + invocationMode: invocationMetadata.invocationMode, + typoGuardTriggered: invocationMetadata.typoGuardTriggered, + webBrowsingRequested: invocationMetadata.webBrowsingRequested, + webBrowsingUsed: invocationMetadata.webBrowsingUsed, context: { currentBranch, operation, @@ -1250,6 +1302,9 @@ async function finalizeHistoryCapture( }).catch(() => { // Do not block command execution if history append fails. }); + + historyArgsForCapture = null; + invocationMetadata = {}; } function truncateHistoryLine(line: string): string { diff --git a/src/lib/ai-context.test.ts b/src/lib/ai-context.test.ts index fb559f7..47d35cb 100644 --- a/src/lib/ai-context.test.ts +++ b/src/lib/ai-context.test.ts @@ -35,6 +35,7 @@ describe('ai context', () => { stack: null, doctor: null, recentHistory: [], + recentShellHistory: [], }); expect(prompt).toContain('CONTEXT_START'); @@ -44,8 +45,29 @@ describe('ai context', () => { }); it('collects context without throwing in a basic git repo', async () => { - const context = await collectAiContext(dir); + const context = await collectAiContext(dir, { + readShellHistory: async () => ['git status', 'dub submit'], + shellHistoryLimit: 200, + }); expect(context.currentBranch).toBe('main'); expect(Array.isArray(context.gitStatusShort)).toBe(true); + expect(context.recentShellHistory).toEqual(['git status', 'dub submit']); + }); + + it('truncates and caps shell history context by budget', async () => { + const context = await collectAiContext(dir, { + readShellHistory: async () => [ + 'a'.repeat(500), + 'b'.repeat(500), + 'c'.repeat(500), + ], + shellHistoryLimit: 200, + shellHistoryLineMaxLength: 100, + shellHistoryTotalCharBudget: 230, + }); + + expect(context.recentShellHistory).toHaveLength(2); + expect(context.recentShellHistory[0].length).toBeLessThanOrEqual(103); + expect(context.recentShellHistory[1].length).toBeLessThanOrEqual(103); }); }); diff --git a/src/lib/ai-context.ts b/src/lib/ai-context.ts index 195da49..4563f66 100644 --- a/src/lib/ai-context.ts +++ b/src/lib/ai-context.ts @@ -1,8 +1,10 @@ import { execa } from 'execa'; import { doctor } from '../commands/doctor'; +import { readConfig } from './config'; import { getCurrentBranch } from './git'; import { readHistory } from './history'; import { detectActiveOperation } from './operation-state'; +import { readRecentShellHistory } from './shell-history'; import { type Branch, findStackForBranch, readState } from './state'; export interface AiContext { @@ -32,9 +34,36 @@ export interface AiContext { output: string[]; errorMessage?: string; }>; + recentShellHistory: string[]; } -export async function collectAiContext(cwd: string): Promise { +export interface CollectAiContextOptions { + readShellHistory?: (limit: number) => Promise; + shellHistoryEnabled?: boolean; + shellHistoryLimit?: number; + shellHistoryLineMaxLength?: number; + shellHistoryTotalCharBudget?: number; +} + +export async function collectAiContext( + cwd: string, + options: CollectAiContextOptions = {}, +): Promise { + const config = await readConfig(cwd).catch(() => null); + const shellHistoryEnabled = + options.shellHistoryEnabled ?? + config?.ai.context.shellHistory.enabled ?? + true; + const shellHistoryLimit = + options.shellHistoryLimit ?? + config?.ai.context.shellHistory.maxCommands ?? + 200; + const shellHistoryLineMaxLength = options.shellHistoryLineMaxLength ?? 220; + const shellHistoryTotalCharBudget = + options.shellHistoryTotalCharBudget ?? 6000; + const readShellHistory = + options.readShellHistory ?? + ((limit: number) => readRecentShellHistory({ maxCommands: limit })); const currentBranch = await getCurrentBranch(cwd).catch(() => null); const activeOperation = await detectActiveOperation(cwd).catch(() => null); const gitStatusShort = await readGitStatusShort(cwd).catch(() => []); @@ -52,6 +81,15 @@ export async function collectAiContext(cwd: string): Promise { output: entry.output.slice(-6).map((line) => truncate(line, 220)), errorMessage: entry.errorMessage, })); + const recentShellHistory = shellHistoryEnabled + ? constrainShellHistoryForContext( + await readShellHistory(shellHistoryLimit).catch(() => []), + { + maxLineLength: shellHistoryLineMaxLength, + totalCharBudget: shellHistoryTotalCharBudget, + }, + ) + : []; return { generatedAt: new Date().toISOString(), @@ -70,6 +108,7 @@ export async function collectAiContext(cwd: string): Promise { } : null, recentHistory, + recentShellHistory, }; } @@ -155,3 +194,23 @@ function truncate(value: string, max: number): string { if (value.length <= max) return value; return `${value.slice(0, max)}...`; } + +function constrainShellHistoryForContext( + lines: string[], + options: { maxLineLength: number; totalCharBudget: number }, +): string[] { + if (lines.length === 0) return []; + const normalized = lines.map((line) => truncate(line, options.maxLineLength)); + const result: string[] = []; + let used = 0; + for (let i = normalized.length - 1; i >= 0; i--) { + const line = normalized[i]; + const cost = line.length + 1; + if (used + cost > options.totalCharBudget) { + break; + } + result.unshift(line); + used += cost; + } + return result; +} diff --git a/src/lib/ai-shortcut.test.ts b/src/lib/ai-shortcut.test.ts new file mode 100644 index 0000000..3eaa5b2 --- /dev/null +++ b/src/lib/ai-shortcut.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + collectKnownTopLevelCommands, + preprocessCliArgs, + type ShortcutChoice, +} from './ai-shortcut'; + +describe('collectKnownTopLevelCommands', () => { + it('includes command names and aliases', () => { + const known = collectKnownTopLevelCommands([ + { name: () => 'submit', aliases: () => ['ss'] }, + { name: () => 'checkout', aliases: () => ['co'] }, + { name: () => 'ai', aliases: () => [] }, + ]); + + expect(known.has('submit')).toBe(true); + expect(known.has('ss')).toBe(true); + expect(known.has('co')).toBe(true); + }); +}); + +describe('preprocessCliArgs', () => { + const known = new Set(['submit', 'create', 'ai', 'history']); + + it('keeps known command args unchanged', async () => { + const result = await preprocessCliArgs( + ['submit', '--dry-run'], + known, + false, + vi.fn(), + ); + + expect(result.finalArgs).toEqual(['submit', '--dry-run']); + expect(result.metadata.invocationMode).toBeUndefined(); + }); + + it('routes unknown command-like input to ai ask', async () => { + const result = await preprocessCliArgs( + ['what', 'changed', 'today?'], + known, + false, + vi.fn(), + ); + + expect(result.finalArgs).toEqual([ + 'ai', + 'ask', + 'what', + 'changed', + 'today?', + ]); + expect(result.metadata.invocationMode).toBe('shortcut-fallback'); + expect(result.metadata.typoGuardTriggered).toBe(false); + }); + + it('forces ai route with --ai', async () => { + const result = await preprocessCliArgs( + ['--ai', 'submit', 'branch', 'status'], + known, + false, + vi.fn(), + ); + + expect(result.finalArgs).toEqual([ + 'ai', + 'ask', + 'submit', + 'branch', + 'status', + ]); + expect(result.metadata.invocationMode).toBe('shortcut-forced'); + }); + + it('errors when --ai is provided without a prompt', async () => { + await expect( + preprocessCliArgs(['--ai'], known, false, vi.fn()), + ).rejects.toThrow('Prompt cannot be empty'); + }); + + it('fails non-interactive likely typos with suggestion', async () => { + await expect( + preprocessCliArgs(['submt'], known, false, vi.fn()), + ).rejects.toThrow("Did you mean 'submit'"); + }); + + it('uses interactive typo choice when tty is available', async () => { + const choose = vi + .fn<(_: string, __: string) => Promise>() + .mockResolvedValue('ask-ai'); + const result = await preprocessCliArgs(['submt'], known, true, choose); + + expect(result.finalArgs).toEqual(['ai', 'ask', 'submt']); + expect(result.metadata.invocationMode).toBe('shortcut-fallback'); + expect(result.metadata.typoGuardTriggered).toBe(true); + }); +}); diff --git a/src/lib/ai-shortcut.ts b/src/lib/ai-shortcut.ts new file mode 100644 index 0000000..cebf850 --- /dev/null +++ b/src/lib/ai-shortcut.ts @@ -0,0 +1,187 @@ +import * as readline from 'node:readline/promises'; +import { DubError } from './errors'; + +export type InvocationMode = + | 'explicit-ai' + | 'shortcut-fallback' + | 'shortcut-forced'; + +export type ShortcutChoice = 'run-command' | 'ask-ai' | 'cancel'; + +export interface ShortcutMetadata { + invocationMode?: InvocationMode; + typoGuardTriggered?: boolean; +} + +export interface PreprocessCliArgsResult { + finalArgs: string[]; + metadata: ShortcutMetadata; +} + +export interface CommandDescriptor { + name: () => string; + aliases: () => string[]; +} + +export function collectKnownTopLevelCommands( + commands: readonly CommandDescriptor[], +): Set { + const known = new Set(); + for (const command of commands) { + known.add(command.name()); + for (const alias of command.aliases()) { + known.add(alias); + } + } + return known; +} + +export async function preprocessCliArgs( + rawArgs: string[], + knownCommands: Set, + isInteractiveTty: boolean, + chooseTypoResolution: ( + input: string, + suggestion: string, + ) => Promise, +): Promise { + if (rawArgs.length === 0) { + return { + finalArgs: rawArgs, + metadata: {}, + }; + } + + const first = rawArgs[0]; + if (first === '--ai') { + const promptArgs = rawArgs.slice(1); + if (promptArgs.length === 0) { + throw new DubError('Prompt cannot be empty when using --ai.'); + } + return { + finalArgs: ['ai', 'ask', ...promptArgs], + metadata: { + invocationMode: 'shortcut-forced', + typoGuardTriggered: false, + }, + }; + } + + if (first.startsWith('-') || knownCommands.has(first)) { + return { + finalArgs: rawArgs, + metadata: {}, + }; + } + + const suggestion = suggestLikelyCommand(first, knownCommands); + if (suggestion) { + if (!isInteractiveTty) { + throw new DubError( + `Unknown command '${first}'. Did you mean '${suggestion}'? Re-run with '--ai' to treat this as an AI prompt.`, + ); + } + + const choice = await chooseTypoResolution(first, suggestion); + if (choice === 'run-command') { + return { + finalArgs: [suggestion, ...rawArgs.slice(1)], + metadata: { + typoGuardTriggered: true, + }, + }; + } + if (choice === 'cancel') { + throw new DubError('Cancelled.'); + } + return { + finalArgs: ['ai', 'ask', ...rawArgs], + metadata: { + invocationMode: 'shortcut-fallback', + typoGuardTriggered: true, + }, + }; + } + + return { + finalArgs: ['ai', 'ask', ...rawArgs], + metadata: { + invocationMode: 'shortcut-fallback', + typoGuardTriggered: false, + }, + }; +} + +export async function promptTypoResolution( + input: string, + suggestion: string, +): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = ( + await rl.question( + `Unknown command '${input}'. Did you mean '${suggestion}'? [c]ommand / [a]i / [x] cancel: `, + ) + ) + .trim() + .toLowerCase(); + if (answer === 'c' || answer === 'command') return 'run-command'; + if (answer === 'x' || answer === 'cancel') return 'cancel'; + return 'ask-ai'; + } finally { + rl.close(); + } +} + +function suggestLikelyCommand( + input: string, + knownCommands: Set, +): string | null { + const normalizedInput = input.toLowerCase(); + let best: { command: string; distance: number } | null = null; + for (const command of knownCommands) { + const normalizedCommand = command.toLowerCase(); + const distance = levenshtein(normalizedInput, normalizedCommand); + if (!best || distance < best.distance) { + best = { command, distance }; + } + } + if (!best) return null; + + const prefixLike = + best.command.startsWith(input) || input.startsWith(best.command); + if (best.distance <= 2 || prefixLike) { + return best.command; + } + + return null; +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const previous = Array.from({ length: b.length + 1 }, (_, idx) => idx); + const current = new Array(b.length + 1).fill(0); + + for (let i = 1; i <= a.length; i++) { + current[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + current[j] = Math.min( + current[j - 1] + 1, + previous[j] + 1, + previous[j - 1] + cost, + ); + } + for (let j = 0; j <= b.length; j++) { + previous[j] = current[j]; + } + } + + return previous[b.length]; +} diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index d52a60f..341f0b0 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -23,6 +23,23 @@ describe('readConfig', () => { const config = await readConfig(dir); expect(config).toEqual({ aiAssistantEnabled: false, + ai: { + shortcutFallback: { + enabled: true, + typoGuard: 'interactive', + nonTtyPolicy: 'error-with-suggestion', + }, + context: { + shellHistory: { + enabled: true, + maxCommands: 200, + }, + }, + webBrowsing: { + mode: 'model-native', + fallback: 'graceful', + }, + }, }); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 2de3884..02044e8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -5,10 +5,44 @@ import { getDubDir } from './state'; export interface DubConfig { aiAssistantEnabled: boolean; + ai: { + shortcutFallback: { + enabled: boolean; + typoGuard: 'interactive'; + nonTtyPolicy: 'error-with-suggestion'; + }; + context: { + shellHistory: { + enabled: boolean; + maxCommands: number; + }; + }; + webBrowsing: { + mode: 'model-native'; + fallback: 'graceful'; + }; + }; } const DEFAULT_CONFIG: DubConfig = { aiAssistantEnabled: false, + ai: { + shortcutFallback: { + enabled: true, + typoGuard: 'interactive', + nonTtyPolicy: 'error-with-suggestion', + }, + context: { + shellHistory: { + enabled: true, + maxCommands: 200, + }, + }, + webBrowsing: { + mode: 'model-native', + fallback: 'graceful', + }, + }, }; export async function getConfigPath(cwd: string): Promise { @@ -34,7 +68,7 @@ export async function readConfig(cwd: string): Promise { } export async function writeConfig( - config: DubConfig, + config: Partial, cwd: string, ): Promise { const configPath = await getConfigPath(cwd); @@ -47,10 +81,53 @@ export async function writeConfig( } function normalizeConfig(config: Partial): DubConfig { + const fallback = config.ai?.shortcutFallback; + const shellHistory = config.ai?.context?.shellHistory; + const webBrowsing = config.ai?.webBrowsing; + return { aiAssistantEnabled: typeof config.aiAssistantEnabled === 'boolean' ? config.aiAssistantEnabled : DEFAULT_CONFIG.aiAssistantEnabled, + ai: { + shortcutFallback: { + enabled: + typeof fallback?.enabled === 'boolean' + ? fallback.enabled + : DEFAULT_CONFIG.ai.shortcutFallback.enabled, + typoGuard: + fallback?.typoGuard === 'interactive' + ? fallback.typoGuard + : DEFAULT_CONFIG.ai.shortcutFallback.typoGuard, + nonTtyPolicy: + fallback?.nonTtyPolicy === 'error-with-suggestion' + ? fallback.nonTtyPolicy + : DEFAULT_CONFIG.ai.shortcutFallback.nonTtyPolicy, + }, + context: { + shellHistory: { + enabled: + typeof shellHistory?.enabled === 'boolean' + ? shellHistory.enabled + : DEFAULT_CONFIG.ai.context.shellHistory.enabled, + maxCommands: + Number.isInteger(shellHistory?.maxCommands) && + (shellHistory?.maxCommands ?? 0) > 0 + ? (shellHistory?.maxCommands as number) + : DEFAULT_CONFIG.ai.context.shellHistory.maxCommands, + }, + }, + webBrowsing: { + mode: + webBrowsing?.mode === 'model-native' + ? webBrowsing.mode + : DEFAULT_CONFIG.ai.webBrowsing.mode, + fallback: + webBrowsing?.fallback === 'graceful' + ? webBrowsing.fallback + : DEFAULT_CONFIG.ai.webBrowsing.fallback, + }, + }, }; } diff --git a/src/lib/history.test.ts b/src/lib/history.test.ts index 740ca6a..2e9d845 100644 --- a/src/lib/history.test.ts +++ b/src/lib/history.test.ts @@ -38,11 +38,17 @@ describe('history', () => { durationMs: 20, output: ['bad'], errorMessage: 'boom', + invocationMode: 'shortcut-fallback', + typoGuardTriggered: true, + webBrowsingRequested: true, + webBrowsingUsed: false, }); const entries = await readHistory(dir, { limit: 10 }); expect(entries).toHaveLength(2); expect(entries[0].command).toBe('dub doctor'); + expect(entries[0].invocationMode).toBe('shortcut-fallback'); + expect(entries[0].webBrowsingUsed).toBe(false); expect(entries[1].command).toBe('dub log'); }); diff --git a/src/lib/history.ts b/src/lib/history.ts index 7cd8f29..13d8abb 100644 --- a/src/lib/history.ts +++ b/src/lib/history.ts @@ -9,6 +9,10 @@ export interface DubHistoryEntry { durationMs: number; output: string[]; errorMessage?: string; + invocationMode?: 'explicit-ai' | 'shortcut-fallback' | 'shortcut-forced'; + typoGuardTriggered?: boolean; + webBrowsingRequested?: boolean; + webBrowsingUsed?: boolean; context?: { currentBranch?: string; operation?: string; diff --git a/src/lib/shell-history.test.ts b/src/lib/shell-history.test.ts new file mode 100644 index 0000000..4f070e4 --- /dev/null +++ b/src/lib/shell-history.test.ts @@ -0,0 +1,98 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { readRecentShellHistory } from './shell-history'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs) { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; +}); + +describe('readRecentShellHistory', () => { + it('parses zsh history and strips metadata prefixes', async () => { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'dub-zsh-')); + tempDirs.push(home); + await fs.promises.writeFile( + path.join(home, '.zsh_history'), + ": 1700000000:0;git status\n: 1700000001:0;dub ai ask 'hello'\n", + 'utf8', + ); + + const entries = await readRecentShellHistory({ + homeDir: home, + shell: '/bin/zsh', + maxCommands: 20, + }); + + expect(entries).toEqual(['git status', "dub ai ask 'hello'"]); + }); + + it('parses bash history and returns bounded recent lines', async () => { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'dub-bash-')); + tempDirs.push(home); + await fs.promises.writeFile( + path.join(home, '.bash_history'), + 'echo one\necho two\necho three\n', + 'utf8', + ); + + const entries = await readRecentShellHistory({ + homeDir: home, + shell: '/bin/bash', + maxCommands: 2, + }); + + expect(entries).toEqual(['echo two', 'echo three']); + }); + + it('redacts sensitive fragments in captured history', async () => { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'dub-red-')); + tempDirs.push(home); + await fs.promises.writeFile( + path.join(home, '.zsh_history'), + [ + ': 1700000000:0;export DUBSTACK_GEMINI_API_KEY=abc123', + ': 1700000001:0;curl -H "Authorization: Bearer super-secret"', + ].join('\n'), + 'utf8', + ); + + const entries = await readRecentShellHistory({ + homeDir: home, + shell: '/bin/zsh', + maxCommands: 20, + }); + + expect(entries.join('\n')).toContain('[REDACTED]'); + expect(entries.join('\n')).not.toContain('abc123'); + expect(entries.join('\n')).not.toContain('super-secret'); + }); + + it('reads recent lines from large history files', async () => { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'dub-big-')); + tempDirs.push(home); + const lines = Array.from({ length: 20000 }, (_, i) => `echo line-${i + 1}`); + await fs.promises.writeFile( + path.join(home, '.bash_history'), + `${lines.join('\n')}\n`, + 'utf8', + ); + + const entries = await readRecentShellHistory({ + homeDir: home, + shell: '/bin/bash', + maxCommands: 3, + }); + + expect(entries).toEqual([ + 'echo line-19998', + 'echo line-19999', + 'echo line-20000', + ]); + }); +}); diff --git a/src/lib/shell-history.ts b/src/lib/shell-history.ts new file mode 100644 index 0000000..7cf4896 --- /dev/null +++ b/src/lib/shell-history.ts @@ -0,0 +1,90 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { redactSensitiveText } from './history'; + +export interface ReadRecentShellHistoryOptions { + homeDir?: string; + shell?: string; + maxCommands?: number; +} + +export async function readRecentShellHistory( + options: ReadRecentShellHistoryOptions = {}, +): Promise { + const homeDir = options.homeDir ?? os.homedir(); + const shell = options.shell ?? process.env.SHELL ?? ''; + const maxCommands = options.maxCommands ?? 200; + if (maxCommands <= 0) return []; + + const files = getCandidateHistoryFiles(homeDir, shell); + const collected: string[] = []; + for (const file of files) { + const parsed = await readHistoryFile(file, maxCommands); + collected.push(...parsed); + } + + return collected + .slice(-maxCommands) + .map((line) => redactSensitiveText(line)) + .filter((line) => line.trim().length > 0); +} + +function getCandidateHistoryFiles(homeDir: string, shell: string): string[] { + const normalized = shell.toLowerCase(); + if (normalized.includes('zsh')) { + return [path.join(homeDir, '.zsh_history')]; + } + if (normalized.includes('bash')) { + return [path.join(homeDir, '.bash_history')]; + } + return [ + path.join(homeDir, '.zsh_history'), + path.join(homeDir, '.bash_history'), + ]; +} + +async function readHistoryFile( + filePath: string, + maxCommands: number, +): Promise { + try { + const raw = await readTailBytes(filePath, { + maxBytes: Math.max(64 * 1024, maxCommands * 1024), + }); + return raw + .split('\n') + .map(parseHistoryLine) + .filter((line): line is string => line !== null); + } catch { + return []; + } +} + +async function readTailBytes( + filePath: string, + options: { maxBytes: number }, +): Promise { + const handle = await fs.open(filePath, 'r'); + try { + const stats = await handle.stat(); + if (stats.size <= 0) return ''; + const toRead = Math.min(stats.size, options.maxBytes); + const start = stats.size - toRead; + const buffer = Buffer.allocUnsafe(toRead); + await handle.read(buffer, 0, toRead, start); + return buffer.toString('utf8'); + } finally { + await handle.close(); + } +} + +function parseHistoryLine(line: string): string | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + const zshMatch = trimmed.match(/^:\s+\d+:\d+;(.*)$/); + if (zshMatch) { + return zshMatch[1].trim(); + } + return trimmed; +}