Skip to content

Commit 1aed28f

Browse files
MORG-13: Add option to use Claude CLI for AI prompts instead of Anthropic API (#43)
* feat(MORG-13): add Claude CLI AI provider Adds a `claude-cli` backend that calls the local `claude -p` binary instead of hitting the Anthropic API, so users with a Claude subscription but no API key can use AI features (standup, PR descriptions, PR review). - New `ClaudeCLIProvider` implementing `AIProvider` via `claude --print` - `aiProvider` field in `GlobalConfigSchema` (`anthropic-api` | `claude-cli`) - Registry `ai()` returns `ClaudeCLIProvider` when `aiProvider === 'claude-cli'` - Config wizard gains an AI provider `select` prompt; `--show` displays it - Standup error message updated to be provider-agnostic - 9 integration tests for `ClaudeCLIProvider` * fix: add missing null-coalescing in password() prompt wrapper clack v1.1 password() can return undefined for empty input; add ?? '' fallback (matching text()) to prevent "Cannot read properties of undefined (reading 'trim')" errors in the config wizard. * fix: remove unsupported --tools flag from claude CLI invocation --tools is not available in all claude CLI versions; drop it since --print alone is sufficient for text-only completions. * fix: drop --no-session-persistence flag for wider claude CLI compatibility * fix: pipe prompt via stdin instead of positional arg Large prompts (e.g. PR diffs) stall when passed as a CLI argument; piping via stdin handles arbitrary-length input cleanly. * fix: unset CLAUDECODE env var when spawning claude CLI When morg is invoked from within Claude Code, the CLAUDECODE env var is set. The claude binary refuses to run nested sessions and exits with an error. Strip it (and CLAUDE_CODE_ENTRYPOINT) from the child process environment so morg can call claude --print from any context. * feat: multi-line PR body editor using @inquirer/editor Replace the single-line text() prompt for PR body with an @inquirer/editor prompt that opens $EDITOR, allowing full multi-line editing of AI-generated PR descriptions. * fix: restore --tools and --no-session-persistence flags These are supported by claude CLI >=2.1.69. The flags were removed during debugging but work correctly with the current version. * feat: replace @inquirer/editor with $EDITOR-based multi-line prompt Drop the 700KB+ inquirer dependency. The editor() prompt writes initial content to a temp file, opens $VISUAL/$EDITOR/vi with inherited stdio, and reads back the result — same UX, zero new deps. * fix: ask AI provider before API key, add 'none' option - Move AI provider select before the API key prompt - Only show the Anthropic API key prompt when user picks 'anthropic-api' - Add 'None (disable AI features)' as a first-class option * feat: replace integration confirm prompts with multiselect Single 'Integrations to enable' multiselect (Jira / Notion / Slack) replaces three separate yes/no prompts. Credentials are only asked for selected providers. Jira and Notion are grouped with a 'tickets provider' hint so users know only one can be active per project. * feat(MORG-13): update AI prompts to structured format with ticket URL support - SYSTEM_PR_DESCRIPTION: enforce ## Ticket / ## Summary / ## Test plan structure with explicit "no title/preamble" instruction - SYSTEM_PR_REVIEW: explicit **What it does** / **Potential concerns** / **Overall assessment** sections - SYSTEM_STANDUP: **Yesterday** / **Today** / **Blockers** structure - prDescriptionPrompt: add ticketId + ticketUrl params; builds linked ticket reference ([ID](url): title) when URL is available - BranchSchema: add ticketUrl field (nullable, default null) - start.ts: store ticket.url as ticketUrl when creating branch entry - track.ts: include ticketUrl: null in new branch entries - pr.ts: pass ticketId + ticketUrl to prDescriptionPrompt
1 parent d55594d commit 1aed28f

11 files changed

Lines changed: 289 additions & 59 deletions

File tree

src/commands/config.ts

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Command } from 'commander';
22
import { configManager } from '../config/manager';
33
import type { GlobalConfig } from '../config/schemas';
44
import { theme, symbols } from '../ui/theme';
5-
import { intro, outro, text, password, confirm, select } from '../ui/prompts';
5+
import { intro, outro, text, password, select, multiselect } from '../ui/prompts';
66
import { requireTrackedRepo } from '../utils/detect';
77

88
async function runConfigWizard(existing: GlobalConfig | undefined): Promise<GlobalConfig> {
@@ -12,13 +12,26 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
1212
validate: (v) => (v?.trim() ? undefined : 'Required'),
1313
});
1414

15-
const anthropicApiKeyRaw = await text({
16-
message: 'Anthropic API key (sk-ant-...) — leave blank to skip',
17-
initialValue: existing?.anthropicApiKey ?? '',
18-
validate: (v) =>
19-
!v?.trim() || v.startsWith('sk-ant-') ? undefined : 'Must start with sk-ant-',
20-
});
21-
const anthropicApiKey = anthropicApiKeyRaw.trim() || undefined;
15+
const aiProvider = (await select({
16+
message: 'AI provider',
17+
options: [
18+
{ value: 'none', label: 'None (disable AI features)' },
19+
{ value: 'anthropic-api', label: 'Anthropic API (API key)' },
20+
{ value: 'claude-cli', label: 'Claude CLI (local claude binary)' },
21+
],
22+
initialValue: (existing?.aiProvider ?? existing?.anthropicApiKey) ? 'anthropic-api' : 'none',
23+
})) as GlobalConfig['aiProvider'] | 'none';
24+
25+
let anthropicApiKey: string | undefined;
26+
if (aiProvider === 'anthropic-api') {
27+
const raw = await text({
28+
message: 'Anthropic API key (sk-ant-...)',
29+
initialValue: existing?.anthropicApiKey ?? '',
30+
validate: (v) =>
31+
!v?.trim() || v.startsWith('sk-ant-') ? undefined : 'Must start with sk-ant-',
32+
});
33+
anthropicApiKey = raw.trim() || undefined;
34+
}
2235

2336
const autoStash = await select({
2437
message: 'Auto-stash dirty working tree on branch switch?',
@@ -50,13 +63,24 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
5063
initialValue: existing?.autoUpdateTicketStatus ?? 'ask',
5164
});
5265

53-
const enableJira = await confirm({
54-
message: 'Enable Jira integration?',
55-
initialValue: existing?.integrations.jira?.enabled ?? false,
66+
type Integration = 'jira' | 'notion' | 'slack';
67+
const currentIntegrations: Integration[] = [];
68+
if (existing?.integrations.jira?.enabled) currentIntegrations.push('jira');
69+
if (existing?.integrations.notion?.enabled) currentIntegrations.push('notion');
70+
if (existing?.integrations.slack?.enabled) currentIntegrations.push('slack');
71+
72+
const enabledIntegrations = await multiselect<Integration>({
73+
message: 'Integrations to enable (space to toggle)',
74+
options: [
75+
{ value: 'jira', label: 'Jira', hint: 'tickets provider' },
76+
{ value: 'notion', label: 'Notion', hint: 'tickets provider' },
77+
{ value: 'slack', label: 'Slack', hint: 'messaging / standup' },
78+
],
79+
initialValues: currentIntegrations,
5680
});
5781

5882
let jiraConfig: GlobalConfig['integrations']['jira'] = undefined;
59-
if (enableJira) {
83+
if (enabledIntegrations.includes('jira')) {
6084
const baseUrl = await text({
6185
message: 'Jira base URL (e.g. https://yourorg.atlassian.net)',
6286
initialValue: existing?.integrations.jira?.baseUrl,
@@ -78,13 +102,21 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
78102
jiraConfig = { enabled: true, baseUrl, userEmail, apiToken };
79103
}
80104

81-
const enableSlack = await confirm({
82-
message: 'Enable Slack integration?',
83-
initialValue: existing?.integrations.slack?.enabled ?? false,
84-
});
105+
let notionConfig: GlobalConfig['integrations']['notion'] = undefined;
106+
if (enabledIntegrations.includes('notion')) {
107+
const existingNotionToken = existing?.integrations.notion?.apiToken;
108+
const notionApiTokenRaw = await password({
109+
message: existingNotionToken
110+
? 'Notion integration token (secret_...) — leave blank to keep existing'
111+
: 'Notion integration token (secret_...)',
112+
validate: (v) => (!v?.trim() && !existingNotionToken ? 'Required' : undefined),
113+
});
114+
const apiToken = notionApiTokenRaw.trim() || existingNotionToken!;
115+
notionConfig = { enabled: true, apiToken };
116+
}
85117

86118
let slackConfig: GlobalConfig['integrations']['slack'] = undefined;
87-
if (enableSlack) {
119+
if (enabledIntegrations.includes('slack')) {
88120
const existingSlackToken = existing?.integrations.slack?.apiToken;
89121
const slackApiTokenRaw = await password({
90122
message: existingSlackToken
@@ -103,28 +135,11 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise<Glob
103135
slackConfig = { enabled: true, apiToken, standupChannel: standupChannel.trim() || undefined };
104136
}
105137

106-
const enableNotion = await confirm({
107-
message: 'Enable Notion integration?',
108-
initialValue: existing?.integrations.notion?.enabled ?? false,
109-
});
110-
111-
let notionConfig: GlobalConfig['integrations']['notion'] = undefined;
112-
if (enableNotion) {
113-
const existingNotionToken = existing?.integrations.notion?.apiToken;
114-
const notionApiTokenRaw = await password({
115-
message: existingNotionToken
116-
? 'Notion integration token (secret_...) — leave blank to keep existing'
117-
: 'Notion integration token (secret_...)',
118-
validate: (v) => (!v?.trim() && !existingNotionToken ? 'Required' : undefined),
119-
});
120-
const apiToken = notionApiTokenRaw.trim() || existingNotionToken!;
121-
notionConfig = { enabled: true, apiToken };
122-
}
123-
124138
return {
125139
version: 1,
126140
githubUsername,
127141
anthropicApiKey,
142+
aiProvider: aiProvider === 'none' ? undefined : aiProvider,
128143
autoStash,
129144
lastStashChoice: existing?.lastStashChoice,
130145
autoDeleteMerged,
@@ -162,6 +177,7 @@ async function runConfig(options: { show?: boolean }): Promise<void> {
162177
console.log(` githubUsername: ${theme.primary(config.githubUsername)}`);
163178
if (config.anthropicApiKey)
164179
console.log(` anthropicApiKey: ${theme.muted(redact(config.anthropicApiKey))}`);
180+
if (config.aiProvider) console.log(` aiProvider: ${theme.primary(config.aiProvider)}`);
165181
console.log(` autoStash: ${theme.primary(config.autoStash)}`);
166182
console.log(` autoDeleteMerged: ${theme.primary(config.autoDeleteMerged)}`);
167183
console.log(` autoUpdateTicketStatus: ${theme.primary(config.autoUpdateTicketStatus)}`);

src/commands/pr.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { requireTrackedRepo } from '../utils/detect';
1313
import { findBranchCaseInsensitive } from '../utils/ticket';
1414
import { theme, symbols } from '../ui/theme';
1515
import { withSpinner } from '../ui/spinner';
16-
import { intro, outro, text } from '../ui/prompts';
16+
import { intro, outro, text, editor } from '../ui/prompts';
1717
import { registry } from '../services/registry';
1818

1919
async function runPrCreate(options: {
@@ -65,7 +65,13 @@ async function runPrCreate(options: {
6565
const diff = await withSpinner('Getting diff...', () => getDiffWithBase(defaultBranch));
6666
bodyDefault = await withSpinner('Generating PR description with Claude...', () =>
6767
ai.complete(
68-
prDescriptionPrompt(diff, currentBranch, trackedBranch?.ticketTitle ?? undefined),
68+
prDescriptionPrompt(
69+
diff,
70+
currentBranch,
71+
trackedBranch?.ticketTitle ?? undefined,
72+
trackedBranch?.ticketId ?? undefined,
73+
trackedBranch?.ticketUrl ?? undefined,
74+
),
6975
SYSTEM_PR_DESCRIPTION,
7076
),
7177
);
@@ -80,10 +86,9 @@ async function runPrCreate(options: {
8086

8187
const body = options.yes
8288
? bodyDefault
83-
: await text({
84-
message: 'PR body (optional)',
89+
: await editor({
90+
message: 'PR body (Markdown)',
8591
initialValue: bodyDefault,
86-
placeholder: 'Leave blank to skip',
8792
});
8893

8994
const remoteHasBase =

src/commands/standup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ async function runStandup(options: { post?: boolean; channel?: string }): Promis
2727
const ai = await registry.ai();
2828
if (!ai) {
2929
console.error(
30-
theme.error('Anthropic API key is required for standup.'),
31-
theme.muted('Run: morg config'),
30+
theme.error('An AI provider is required for standup.'),
31+
theme.muted('Run: morg config — set an Anthropic API key or enable Claude CLI'),
3232
);
3333
process.exit(1);
3434
}

src/commands/start.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export async function runStart(
2828
let branchName: string;
2929
let ticketId: string | null = null;
3030
let ticketTitle: string | null = null;
31+
let ticketUrl: string | null = null;
3132

3233
if (isTicketId(input)) {
3334
const candidateId = input.trim().toUpperCase();
@@ -42,6 +43,7 @@ export async function runStart(
4243
const ticket = await fetchTicket(ticketsProvider, candidateId);
4344
ticketId = ticket.key;
4445
ticketTitle = ticket.title;
46+
ticketUrl = ticket.url ?? null;
4547
} catch (err) {
4648
const msg = err instanceof Error ? err.message : String(err);
4749
console.log(theme.warning(` ${symbols.warning} Could not fetch ticket: ${msg}`));
@@ -134,6 +136,7 @@ export async function runStart(
134136
branchName,
135137
ticketId,
136138
ticketTitle,
139+
ticketUrl,
137140
status: 'active',
138141
createdAt: now,
139142
updatedAt: now,

src/commands/track.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ async function runTrack(branch?: string, ticket?: string): Promise<void> {
7070
prUrl: null,
7171
prStatus: null,
7272
worktreePath: null,
73+
ticketUrl: null,
7374
});
7475
await configManager.saveBranches(projectId, branchesFile);
7576
console.log(theme.success(`${symbols.success} Now tracking ${branchName}`));

src/config/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const GlobalConfigSchema = z.object({
3030
version: z.literal(1),
3131
githubUsername: z.string().min(1),
3232
anthropicApiKey: z.string().min(1).optional(),
33+
aiProvider: z.enum(['anthropic-api', 'claude-cli']).optional(),
3334
autoStash: z.enum(['always', 'ask', 'never']).default('ask'),
3435
lastStashChoice: z.enum(['stash', 'skip']).optional(),
3536
autoDeleteMerged: z.enum(['always', 'ask', 'never']).default('ask'),
@@ -128,6 +129,7 @@ export const BranchSchema = z.object({
128129
prStatus: PrStatusSchema,
129130
worktreePath: z.string().nullable().default(null),
130131
lastAccessedAt: z.string().datetime().optional(),
132+
ticketUrl: z.string().url().nullable().default(null),
131133
});
132134

133135
export const BranchesFileSchema = z.object({
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { execa } from 'execa';
2+
import type { AIProvider } from '../ai-provider';
3+
import { IntegrationError } from '../../../../utils/errors';
4+
5+
export class ClaudeCLIProvider implements AIProvider {
6+
async complete(prompt: string, systemPrompt?: string): Promise<string> {
7+
const args: string[] = ['--print', '--tools', '', '--no-session-persistence'];
8+
if (systemPrompt) args.push('--system-prompt', systemPrompt);
9+
10+
const env = { ...process.env };
11+
delete env['CLAUDECODE'];
12+
delete env['CLAUDE_CODE_ENTRYPOINT'];
13+
14+
let result;
15+
try {
16+
result = await execa('claude', args, { input: prompt, reject: false, env });
17+
} catch (err: unknown) {
18+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
19+
throw new IntegrationError(
20+
'Claude CLI binary not found.',
21+
'claude-cli',
22+
'Install the Claude CLI: https://claude.ai/download',
23+
);
24+
}
25+
throw err;
26+
}
27+
28+
if (result.exitCode !== 0) {
29+
throw new IntegrationError(
30+
`Claude CLI exited with code ${result.exitCode}: ${result.stderr}`,
31+
'claude-cli',
32+
'Ensure the claude CLI is installed and authenticated. Run: claude --version',
33+
);
34+
}
35+
36+
return result.stdout.trim();
37+
}
38+
}

src/integrations/providers/ai/prompts.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,59 @@
11
export const SYSTEM_PR_DESCRIPTION = `You are a senior software engineer writing a GitHub pull request description.
2-
Write a clear, concise PR description in markdown. Include:
3-
- A brief summary of what the PR does
4-
- Key changes made
5-
- Any testing notes
6-
Be direct and technical. No fluff.`;
2+
Output ONLY the following Markdown body — no title, no preamble, no extra sections:
3+
4+
## Ticket
5+
<ticket reference: "[ID](url): title" if a URL is provided, otherwise "ID: title", or "N/A">
6+
7+
## Summary
8+
<2–5 bullet points: what changed and why>
9+
10+
## Test plan
11+
<bullet-point checklist: how to verify the change>
12+
13+
Start your response directly with "## Ticket". Be direct and technical. No fluff.`;
714

815
export const SYSTEM_PR_REVIEW = `You are a senior software engineer reviewing a pull request.
9-
Provide a concise summary of the diff, highlighting:
10-
- What the change does
11-
- Any potential concerns or edge cases
12-
- Overall assessment (looks good / needs attention)
16+
Provide a concise review with these sections:
17+
18+
**What it does** — 1–3 bullets summarising the change
19+
20+
**Potential concerns** — numbered list of issues/edge cases, or "None identified"
21+
22+
**Overall assessment** — one line: ✅ Looks good | ⚠ Needs attention | ❌ Needs rework
23+
1324
Be brief and direct.`;
1425

15-
export const SYSTEM_STANDUP = `You are helping a developer write a standup update.
16-
Based on the recent git activity and task data, generate a brief standup:
17-
- What I did yesterday
18-
- What I'm doing today
19-
- Any blockers
26+
export const SYSTEM_STANDUP = `You are helping a developer write a daily standup update.
27+
Output exactly this Markdown structure:
28+
29+
**Yesterday**
30+
- <what was completed>
2031
21-
Keep it to 3-5 bullet points total. Be concise.`;
32+
**Today**
33+
- <what is planned>
34+
35+
**Blockers**
36+
- <blockers, or "None">
37+
38+
1–3 bullets per section. Be concise and specific.`;
2239

2340
export function prDescriptionPrompt(
2441
diff: string,
2542
branchName: string,
2643
ticketTitle?: string,
44+
ticketId?: string,
45+
ticketUrl?: string,
2746
): string {
47+
let ticketRef = 'N/A';
48+
if (ticketId) {
49+
const label = ticketTitle ? `${ticketId}: ${ticketTitle}` : ticketId;
50+
ticketRef = ticketUrl ? `[${label}](${ticketUrl})` : label;
51+
} else if (ticketTitle) {
52+
ticketRef = ticketTitle;
53+
}
54+
2855
return `Generate a PR description for this branch: ${branchName}
29-
${ticketTitle ? `Ticket: ${ticketTitle}` : ''}
56+
Ticket: ${ticketRef}
3057
3158
Diff:
3259
\`\`\`diff

src/services/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { GhClient } from '../integrations/providers/github/github-client';
44
import { JiraClient } from '../integrations/providers/tickets/implementations/jira-tickets-provider';
55
import { NotionClient } from '../integrations/providers/tickets/implementations/notion-tickets-provider';
66
import { ClaudeClient } from '../integrations/providers/ai/implementations/claude-ai-provider';
7+
import { ClaudeCLIProvider } from '../integrations/providers/ai/implementations/claude-cli-ai-provider';
78
import { SlackClient } from '../integrations/providers/messaging/implementations/slack-messaging-provider';
89
import type { TicketsProvider } from '../integrations/providers/tickets/tickets-provider';
910
import type { AIProvider } from '../integrations/providers/ai/ai-provider';
@@ -41,6 +42,7 @@ class Registry {
4142

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

0 commit comments

Comments
 (0)