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
15 changes: 10 additions & 5 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 @@ -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,
),
);
Expand All @@ -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 =
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
3 changes: 3 additions & 0 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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}`));
Expand Down Expand Up @@ -134,6 +136,7 @@ export async function runStart(
branchName,
ticketId,
ticketTitle,
ticketUrl,
status: 'active',
createdAt: now,
updatedAt: now,
Expand Down
1 change: 1 addition & 0 deletions src/commands/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ async function runTrack(branch?: string, ticket?: string): Promise<void> {
prUrl: null,
prStatus: null,
worktreePath: null,
ticketUrl: null,
});
await configManager.saveBranches(projectId, branchesFile);
console.log(theme.success(`${symbols.success} Now tracking ${branchName}`));
Expand Down
2 changes: 2 additions & 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 Expand Up @@ -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({
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();
}
}
59 changes: 43 additions & 16 deletions src/integrations/providers/ai/prompts.ts
Original file line number Diff line number Diff line change
@@ -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
<ticket reference: "[ID](url): title" if a URL is provided, otherwise "ID: title", or "N/A">

## Summary
<2–5 bullet points: what changed and why>

## Test plan
<bullet-point checklist: how to verify the change>

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**
- <what was completed>

Keep it to 3-5 bullet points total. Be concise.`;
**Today**
- <what is planned>

**Blockers**
- <blockers, or "None">

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
Expand Down
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
Loading