diff --git a/src/commands/config.ts b/src/commands/config.ts index 657312d..a9f0c92 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import { configManager } from '../config/manager'; import type { GlobalConfig } from '../config/schemas'; import { theme, symbols } from '../ui/theme'; -import { intro, outro, text, password, confirm, select } from '../ui/prompts'; +import { intro, outro, text, password, select, multiselect } from '../ui/prompts'; import { requireTrackedRepo } from '../utils/detect'; async function runConfigWizard(existing: GlobalConfig | undefined): Promise { @@ -12,13 +12,26 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise (v?.trim() ? undefined : 'Required'), }); - const anthropicApiKeyRaw = await text({ - message: 'Anthropic API key (sk-ant-...) — leave blank to skip', - initialValue: existing?.anthropicApiKey ?? '', - validate: (v) => - !v?.trim() || v.startsWith('sk-ant-') ? undefined : 'Must start with sk-ant-', - }); - const anthropicApiKey = anthropicApiKeyRaw.trim() || undefined; + const aiProvider = (await select({ + message: 'AI provider', + options: [ + { value: 'none', label: 'None (disable AI features)' }, + { value: 'anthropic-api', label: 'Anthropic API (API key)' }, + { value: 'claude-cli', label: 'Claude CLI (local claude binary)' }, + ], + initialValue: (existing?.aiProvider ?? existing?.anthropicApiKey) ? 'anthropic-api' : 'none', + })) as GlobalConfig['aiProvider'] | 'none'; + + let anthropicApiKey: string | undefined; + if (aiProvider === 'anthropic-api') { + const raw = await text({ + message: 'Anthropic API key (sk-ant-...)', + initialValue: existing?.anthropicApiKey ?? '', + validate: (v) => + !v?.trim() || v.startsWith('sk-ant-') ? undefined : 'Must start with sk-ant-', + }); + anthropicApiKey = raw.trim() || undefined; + } const autoStash = await select({ message: 'Auto-stash dirty working tree on branch switch?', @@ -50,13 +63,24 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise({ + message: 'Integrations to enable (space to toggle)', + options: [ + { value: 'jira', label: 'Jira', hint: 'tickets provider' }, + { value: 'notion', label: 'Notion', hint: 'tickets provider' }, + { value: 'slack', label: 'Slack', hint: 'messaging / standup' }, + ], + initialValues: currentIntegrations, }); let jiraConfig: GlobalConfig['integrations']['jira'] = undefined; - if (enableJira) { + if (enabledIntegrations.includes('jira')) { const baseUrl = await text({ message: 'Jira base URL (e.g. https://yourorg.atlassian.net)', initialValue: existing?.integrations.jira?.baseUrl, @@ -78,13 +102,21 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise (!v?.trim() && !existingNotionToken ? 'Required' : undefined), + }); + const apiToken = notionApiTokenRaw.trim() || existingNotionToken!; + notionConfig = { enabled: true, apiToken }; + } let slackConfig: GlobalConfig['integrations']['slack'] = undefined; - if (enableSlack) { + if (enabledIntegrations.includes('slack')) { const existingSlackToken = existing?.integrations.slack?.apiToken; const slackApiTokenRaw = await password({ message: existingSlackToken @@ -103,28 +135,11 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise (!v?.trim() && !existingNotionToken ? 'Required' : undefined), - }); - const apiToken = notionApiTokenRaw.trim() || existingNotionToken!; - notionConfig = { enabled: true, apiToken }; - } - return { version: 1, githubUsername, anthropicApiKey, + aiProvider: aiProvider === 'none' ? undefined : aiProvider, autoStash, lastStashChoice: existing?.lastStashChoice, autoDeleteMerged, @@ -162,6 +177,7 @@ async function runConfig(options: { show?: boolean }): Promise { console.log(` githubUsername: ${theme.primary(config.githubUsername)}`); if (config.anthropicApiKey) console.log(` anthropicApiKey: ${theme.muted(redact(config.anthropicApiKey))}`); + if (config.aiProvider) console.log(` aiProvider: ${theme.primary(config.aiProvider)}`); console.log(` autoStash: ${theme.primary(config.autoStash)}`); console.log(` autoDeleteMerged: ${theme.primary(config.autoDeleteMerged)}`); console.log(` autoUpdateTicketStatus: ${theme.primary(config.autoUpdateTicketStatus)}`); diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 828c9eb..a5a653c 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -13,7 +13,7 @@ import { requireTrackedRepo } from '../utils/detect'; import { findBranchCaseInsensitive } from '../utils/ticket'; import { theme, symbols } from '../ui/theme'; import { withSpinner } from '../ui/spinner'; -import { intro, outro, text } from '../ui/prompts'; +import { intro, outro, text, editor } from '../ui/prompts'; import { registry } from '../services/registry'; async function runPrCreate(options: { @@ -80,10 +80,9 @@ async function runPrCreate(options: { const body = options.yes ? bodyDefault - : await text({ - message: 'PR body (optional)', + : await editor({ + message: 'PR body (Markdown)', initialValue: bodyDefault, - placeholder: 'Leave blank to skip', }); const remoteHasBase = diff --git a/src/commands/standup.ts b/src/commands/standup.ts index 656c1d3..1a62bc3 100644 --- a/src/commands/standup.ts +++ b/src/commands/standup.ts @@ -27,8 +27,8 @@ async function runStandup(options: { post?: boolean; channel?: string }): Promis const ai = await registry.ai(); if (!ai) { console.error( - theme.error('Anthropic API key is required for standup.'), - theme.muted('Run: morg config'), + theme.error('An AI provider is required for standup.'), + theme.muted('Run: morg config — set an Anthropic API key or enable Claude CLI'), ); process.exit(1); } diff --git a/src/config/schemas.ts b/src/config/schemas.ts index 2ab6cbf..2a37900 100644 --- a/src/config/schemas.ts +++ b/src/config/schemas.ts @@ -30,6 +30,7 @@ export const GlobalConfigSchema = z.object({ version: z.literal(1), githubUsername: z.string().min(1), anthropicApiKey: z.string().min(1).optional(), + aiProvider: z.enum(['anthropic-api', 'claude-cli']).optional(), autoStash: z.enum(['always', 'ask', 'never']).default('ask'), lastStashChoice: z.enum(['stash', 'skip']).optional(), autoDeleteMerged: z.enum(['always', 'ask', 'never']).default('ask'), diff --git a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts new file mode 100644 index 0000000..454f697 --- /dev/null +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -0,0 +1,38 @@ +import { execa } from 'execa'; +import type { AIProvider } from '../ai-provider'; +import { IntegrationError } from '../../../../utils/errors'; + +export class ClaudeCLIProvider implements AIProvider { + async complete(prompt: string, systemPrompt?: string): Promise { + const args: string[] = ['--print', '--tools', '', '--no-session-persistence']; + if (systemPrompt) args.push('--system-prompt', systemPrompt); + + const env = { ...process.env }; + delete env['CLAUDECODE']; + delete env['CLAUDE_CODE_ENTRYPOINT']; + + let result; + try { + result = await execa('claude', args, { input: prompt, reject: false, env }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new IntegrationError( + 'Claude CLI binary not found.', + 'claude-cli', + 'Install the Claude CLI: https://claude.ai/download', + ); + } + throw err; + } + + if (result.exitCode !== 0) { + throw new IntegrationError( + `Claude CLI exited with code ${result.exitCode}: ${result.stderr}`, + 'claude-cli', + 'Ensure the claude CLI is installed and authenticated. Run: claude --version', + ); + } + + return result.stdout.trim(); + } +} diff --git a/src/services/registry.ts b/src/services/registry.ts index ad49700..826d41d 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -4,6 +4,7 @@ import { GhClient } from '../integrations/providers/github/github-client'; import { JiraClient } from '../integrations/providers/tickets/implementations/jira-tickets-provider'; import { NotionClient } from '../integrations/providers/tickets/implementations/notion-tickets-provider'; import { ClaudeClient } from '../integrations/providers/ai/implementations/claude-ai-provider'; +import { ClaudeCLIProvider } from '../integrations/providers/ai/implementations/claude-cli-ai-provider'; import { SlackClient } from '../integrations/providers/messaging/implementations/slack-messaging-provider'; import type { TicketsProvider } from '../integrations/providers/tickets/tickets-provider'; import type { AIProvider } from '../integrations/providers/ai/ai-provider'; @@ -41,6 +42,7 @@ class Registry { async ai(): Promise { const globalConfig = await configManager.getGlobalConfig(await this.pid()); + if (globalConfig.aiProvider === 'claude-cli') return new ClaudeCLIProvider(); return globalConfig.anthropicApiKey ? new ClaudeClient(globalConfig.anthropicApiKey) : null; } diff --git a/src/ui/prompts.ts b/src/ui/prompts.ts index 1e95002..2a52c57 100644 --- a/src/ui/prompts.ts +++ b/src/ui/prompts.ts @@ -1,3 +1,7 @@ +import { writeFileSync, readFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execa } from 'execa'; import * as clack from '@clack/prompts'; export function intro(title: string): void { @@ -31,7 +35,7 @@ export async function password(opts: { clack.cancel('Operation cancelled.'); process.exit(0); } - return result; + return result ?? ''; } export async function confirm(opts: { message: string; initialValue?: boolean }): Promise { @@ -58,4 +62,38 @@ export async function select(opts: { return result as T; } +export async function multiselect(opts: { + message: string; + options: { value: T; label: string; hint?: string }[]; + initialValues?: T[]; + required?: boolean; +}): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await clack.multiselect({ required: false, ...opts } as any); + if (clack.isCancel(result)) { + clack.cancel('Operation cancelled.'); + process.exit(0); + } + return result as T[]; +} + +export async function editor(opts: { message: string; initialValue?: string }): Promise { + const tmpFile = join(tmpdir(), `morg-${Date.now()}.md`); + writeFileSync(tmpFile, opts.initialValue ?? '', 'utf-8'); + + const editorCmd = process.env.VISUAL ?? process.env.EDITOR ?? 'vi'; + clack.log.step(`${opts.message} — opening ${editorCmd}`); + + try { + await execa(editorCmd, [tmpFile], { stdio: 'inherit', reject: false }); + return readFileSync(tmpFile, 'utf-8').trim(); + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore cleanup errors + } + } +} + export { clack }; diff --git a/tests/integrations/claude-cli.test.ts b/tests/integrations/claude-cli.test.ts new file mode 100644 index 0000000..f03ffa8 --- /dev/null +++ b/tests/integrations/claude-cli.test.ts @@ -0,0 +1,98 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { execa } from 'execa'; +import { ClaudeCLIProvider } from '../../src/integrations/providers/ai/implementations/claude-cli-ai-provider'; +import { IntegrationError } from '../../src/utils/errors'; + +vi.mock('execa'); + +const mockExeca = execa as unknown as ReturnType; + +describe('ClaudeCLIProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns trimmed stdout on success', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: ' hello world\n', stderr: '' }); + + const provider = new ClaudeCLIProvider(); + const result = await provider.complete('Say hello'); + + expect(result).toBe('hello world'); + }); + + it('always passes --print', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); + + const provider = new ClaudeCLIProvider(); + await provider.complete('test prompt'); + + const [cmd, args] = mockExeca.mock.calls[0] as [string, string[]]; + expect(cmd).toBe('claude'); + expect(args).toContain('--print'); + }); + + it('passes prompt via stdin input option', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); + + const provider = new ClaudeCLIProvider(); + await provider.complete('my prompt'); + + const [, , opts] = mockExeca.mock.calls[0] as [string, string[], { input: string }]; + expect(opts.input).toBe('my prompt'); + }); + + it('passes --system-prompt arg when systemPrompt is provided', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); + + const provider = new ClaudeCLIProvider(); + await provider.complete('user prompt', 'system instructions'); + + const [, args] = mockExeca.mock.calls[0] as [string, string[]]; + const sysIdx = args.indexOf('--system-prompt'); + expect(sysIdx).toBeGreaterThanOrEqual(0); + expect(args[sysIdx + 1]).toBe('system instructions'); + }); + + it('omits --system-prompt when no systemPrompt given', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); + + const provider = new ClaudeCLIProvider(); + await provider.complete('user prompt'); + + const [, args] = mockExeca.mock.calls[0] as [string, string[]]; + expect(args).not.toContain('--system-prompt'); + }); + + it('throws IntegrationError on non-zero exit code', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'auth error' }); + + const provider = new ClaudeCLIProvider(); + await expect(provider.complete('test')).rejects.toThrow(/exited with code 1/); + }); + + it('throws IntegrationError type on non-zero exit code', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: 'auth error' }); + + const provider = new ClaudeCLIProvider(); + await expect(provider.complete('test')).rejects.toThrow(IntegrationError); + }); + + it('throws IntegrationError when binary not found (ENOENT)', async () => { + const err = new Error('spawn claude ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + mockExeca.mockRejectedValueOnce(err); + + const provider = new ClaudeCLIProvider(); + await expect(provider.complete('test')).rejects.toThrow(/not found/); + }); + + it('throws IntegrationError type when binary not found (ENOENT)', async () => { + const err = new Error('spawn claude ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + mockExeca.mockRejectedValueOnce(err); + + const provider = new ClaudeCLIProvider(); + await expect(provider.complete('test')).rejects.toThrow(IntegrationError); + }); +});