Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 51 additions & 35 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalConfig> {
Expand All @@ -12,13 +12,26 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
validate: (v) => (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?',
Expand Down Expand Up @@ -50,13 +63,24 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
initialValue: existing?.autoUpdateTicketStatus ?? 'ask',
});

const enableJira = await confirm({
message: 'Enable Jira integration?',
initialValue: existing?.integrations.jira?.enabled ?? false,
type Integration = 'jira' | 'notion' | 'slack';
const currentIntegrations: Integration[] = [];
if (existing?.integrations.jira?.enabled) currentIntegrations.push('jira');
if (existing?.integrations.notion?.enabled) currentIntegrations.push('notion');
if (existing?.integrations.slack?.enabled) currentIntegrations.push('slack');

const enabledIntegrations = await multiselect<Integration>({
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,
Expand All @@ -78,13 +102,21 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
jiraConfig = { enabled: true, baseUrl, userEmail, apiToken };
}

const enableSlack = await confirm({
message: 'Enable Slack integration?',
initialValue: existing?.integrations.slack?.enabled ?? false,
});
let notionConfig: GlobalConfig['integrations']['notion'] = undefined;
if (enabledIntegrations.includes('notion')) {
const existingNotionToken = existing?.integrations.notion?.apiToken;
const notionApiTokenRaw = await password({
message: existingNotionToken
? 'Notion integration token (secret_...) — leave blank to keep existing'
: 'Notion integration token (secret_...)',
validate: (v) => (!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
Expand All @@ -103,28 +135,11 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
slackConfig = { enabled: true, apiToken, standupChannel: standupChannel.trim() || undefined };
}

const enableNotion = await confirm({
message: 'Enable Notion integration?',
initialValue: existing?.integrations.notion?.enabled ?? false,
});

let notionConfig: GlobalConfig['integrations']['notion'] = undefined;
if (enableNotion) {
const existingNotionToken = existing?.integrations.notion?.apiToken;
const notionApiTokenRaw = await password({
message: existingNotionToken
? 'Notion integration token (secret_...) — leave blank to keep existing'
: 'Notion integration token (secret_...)',
validate: (v) => (!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,
Expand Down Expand Up @@ -162,6 +177,7 @@ async function runConfig(options: { show?: boolean }): Promise<void> {
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)}`);
Expand Down
7 changes: 3 additions & 4 deletions src/commands/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 =
Expand Down
4 changes: 2 additions & 2 deletions src/commands/standup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/config/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
}
}
2 changes: 2 additions & 0 deletions src/services/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,6 +42,7 @@ class Registry {

async ai(): Promise<AIProvider | null> {
const globalConfig = await configManager.getGlobalConfig(await this.pid());
if (globalConfig.aiProvider === 'claude-cli') return new ClaudeCLIProvider();
return globalConfig.anthropicApiKey ? new ClaudeClient(globalConfig.anthropicApiKey) : null;
}

Expand Down
40 changes: 39 additions & 1 deletion src/ui/prompts.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<boolean> {
Expand All @@ -58,4 +62,38 @@ export async function select<T extends string>(opts: {
return result as T;
}

export async function multiselect<T extends string>(opts: {
message: string;
options: { value: T; label: string; hint?: string }[];
initialValues?: T[];
required?: boolean;
}): Promise<T[]> {
// 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<string> {
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 };
98 changes: 98 additions & 0 deletions tests/integrations/claude-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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);
});
});