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..dc0b0e0 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: { @@ -65,7 +65,13 @@ async function runPrCreate(options: { const diff = await withSpinner('Getting diff...', () => getDiffWithBase(defaultBranch)); bodyDefault = await withSpinner('Generating PR description with Claude...', () => ai.complete( - prDescriptionPrompt(diff, currentBranch, trackedBranch?.ticketTitle ?? undefined), + prDescriptionPrompt( + diff, + currentBranch, + trackedBranch?.ticketTitle ?? undefined, + trackedBranch?.ticketId ?? undefined, + trackedBranch?.ticketUrl ?? undefined, + ), SYSTEM_PR_DESCRIPTION, ), ); @@ -80,10 +86,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/commands/start.ts b/src/commands/start.ts index 878b686..8781868 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -28,6 +28,7 @@ export async function runStart( let branchName: string; let ticketId: string | null = null; let ticketTitle: string | null = null; + let ticketUrl: string | null = null; if (isTicketId(input)) { const candidateId = input.trim().toUpperCase(); @@ -42,6 +43,7 @@ export async function runStart( const ticket = await fetchTicket(ticketsProvider, candidateId); ticketId = ticket.key; ticketTitle = ticket.title; + ticketUrl = ticket.url ?? null; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.log(theme.warning(` ${symbols.warning} Could not fetch ticket: ${msg}`)); @@ -134,6 +136,7 @@ export async function runStart( branchName, ticketId, ticketTitle, + ticketUrl, status: 'active', createdAt: now, updatedAt: now, diff --git a/src/commands/track.ts b/src/commands/track.ts index 131d78f..ccb9ce6 100644 --- a/src/commands/track.ts +++ b/src/commands/track.ts @@ -70,6 +70,7 @@ async function runTrack(branch?: string, ticket?: string): Promise { prUrl: null, prStatus: null, worktreePath: null, + ticketUrl: null, }); await configManager.saveBranches(projectId, branchesFile); console.log(theme.success(`${symbols.success} Now tracking ${branchName}`)); diff --git a/src/config/schemas.ts b/src/config/schemas.ts index 2ab6cbf..fb7c457 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'), @@ -128,6 +129,7 @@ export const BranchSchema = z.object({ prStatus: PrStatusSchema, worktreePath: z.string().nullable().default(null), lastAccessedAt: z.string().datetime().optional(), + ticketUrl: z.string().url().nullable().default(null), }); export const BranchesFileSchema = z.object({ 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/integrations/providers/ai/prompts.ts b/src/integrations/providers/ai/prompts.ts index 0288f9a..1ed41d3 100644 --- a/src/integrations/providers/ai/prompts.ts +++ b/src/integrations/providers/ai/prompts.ts @@ -1,32 +1,59 @@ export const SYSTEM_PR_DESCRIPTION = `You are a senior software engineer writing a GitHub pull request description. -Write a clear, concise PR description in markdown. Include: -- A brief summary of what the PR does -- Key changes made -- Any testing notes -Be direct and technical. No fluff.`; +Output ONLY the following Markdown body — no title, no preamble, no extra sections: + +## Ticket + + +## Summary +<2–5 bullet points: what changed and why> + +## Test plan + + +Start your response directly with "## Ticket". Be direct and technical. No fluff.`; export const SYSTEM_PR_REVIEW = `You are a senior software engineer reviewing a pull request. -Provide a concise summary of the diff, highlighting: -- What the change does -- Any potential concerns or edge cases -- Overall assessment (looks good / needs attention) +Provide a concise review with these sections: + +**What it does** — 1–3 bullets summarising the change + +**Potential concerns** — numbered list of issues/edge cases, or "None identified" + +**Overall assessment** — one line: ✅ Looks good | ⚠ Needs attention | ❌ Needs rework + Be brief and direct.`; -export const SYSTEM_STANDUP = `You are helping a developer write a standup update. -Based on the recent git activity and task data, generate a brief standup: -- What I did yesterday -- What I'm doing today -- Any blockers +export const SYSTEM_STANDUP = `You are helping a developer write a daily standup update. +Output exactly this Markdown structure: + +**Yesterday** +- -Keep it to 3-5 bullet points total. Be concise.`; +**Today** +- + +**Blockers** +- + +1–3 bullets per section. Be concise and specific.`; export function prDescriptionPrompt( diff: string, branchName: string, ticketTitle?: string, + ticketId?: string, + ticketUrl?: string, ): string { + let ticketRef = 'N/A'; + if (ticketId) { + const label = ticketTitle ? `${ticketId}: ${ticketTitle}` : ticketId; + ticketRef = ticketUrl ? `[${label}](${ticketUrl})` : label; + } else if (ticketTitle) { + ticketRef = ticketTitle; + } + return `Generate a PR description for this branch: ${branchName} -${ticketTitle ? `Ticket: ${ticketTitle}` : ''} +Ticket: ${ticketRef} Diff: \`\`\`diff 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); + }); +});