From f688ebb3ac8546a6e44f6bc71a6f564986b703c3 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Mon, 9 Mar 2026 23:12:54 -0300 Subject: [PATCH 01/12] 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` --- src/commands/config.ts | 11 ++ src/commands/standup.ts | 4 +- src/config/schemas.ts | 1 + .../implementations/claude-cli-ai-provider.ts | 34 ++++++ src/services/registry.ts | 2 + tests/integrations/claude-cli.test.ts | 100 ++++++++++++++++++ 6 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts create mode 100644 tests/integrations/claude-cli.test.ts diff --git a/src/commands/config.ts b/src/commands/config.ts index 657312d..62c709b 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -20,6 +20,15 @@ async function runConfigWizard(existing: GlobalConfig | undefined): 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/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..2584a07 --- /dev/null +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -0,0 +1,34 @@ +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 = ['--print', '--tools', '', '--no-session-persistence', prompt]; + if (systemPrompt) args.unshift('--system-prompt', systemPrompt); + + let result; + try { + result = await execa('claude', args, { reject: false }); + } 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/tests/integrations/claude-cli.test.ts b/tests/integrations/claude-cli.test.ts new file mode 100644 index 0000000..e70500a --- /dev/null +++ b/tests/integrations/claude-cli.test.ts @@ -0,0 +1,100 @@ +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, --tools, --no-session-persistence', 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'); + expect(args).toContain('--tools'); + expect(args).toContain('--no-session-persistence'); + }); + + it('includes prompt as last arg', async () => { + mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); + + const provider = new ClaudeCLIProvider(); + await provider.complete('my prompt'); + + const [, args] = mockExeca.mock.calls[0] as [string, string[]]; + expect(args[args.length - 1]).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); + }); +}); From b282fa8b7c6a97d96fdb385a94b565d7f1cdd9cd Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 00:19:54 -0300 Subject: [PATCH 02/12] 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. --- src/ui/prompts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/prompts.ts b/src/ui/prompts.ts index 1e95002..a2ac973 100644 --- a/src/ui/prompts.ts +++ b/src/ui/prompts.ts @@ -31,7 +31,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 { From c305049e1460489a1a73d44d99eb7c19857b78d9 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 00:23:47 -0300 Subject: [PATCH 03/12] 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. --- .../providers/ai/implementations/claude-cli-ai-provider.ts | 2 +- tests/integrations/claude-cli.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts index 2584a07..e9e54bd 100644 --- a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -4,7 +4,7 @@ import { IntegrationError } from '../../../../utils/errors'; export class ClaudeCLIProvider implements AIProvider { async complete(prompt: string, systemPrompt?: string): Promise { - const args = ['--print', '--tools', '', '--no-session-persistence', prompt]; + const args = ['--print', '--no-session-persistence', prompt]; if (systemPrompt) args.unshift('--system-prompt', systemPrompt); let result; diff --git a/tests/integrations/claude-cli.test.ts b/tests/integrations/claude-cli.test.ts index e70500a..b27e34b 100644 --- a/tests/integrations/claude-cli.test.ts +++ b/tests/integrations/claude-cli.test.ts @@ -21,7 +21,7 @@ describe('ClaudeCLIProvider', () => { expect(result).toBe('hello world'); }); - it('always passes --print, --tools, --no-session-persistence', async () => { + it('always passes --print and --no-session-persistence', async () => { mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); const provider = new ClaudeCLIProvider(); @@ -30,7 +30,7 @@ describe('ClaudeCLIProvider', () => { const [cmd, args] = mockExeca.mock.calls[0] as [string, string[]]; expect(cmd).toBe('claude'); expect(args).toContain('--print'); - expect(args).toContain('--tools'); + expect(args).not.toContain('--tools'); expect(args).toContain('--no-session-persistence'); }); From a341c1e38f4fe134583948f944a0fb38110d94a3 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 00:26:42 -0300 Subject: [PATCH 04/12] fix: drop --no-session-persistence flag for wider claude CLI compatibility --- .../providers/ai/implementations/claude-cli-ai-provider.ts | 2 +- tests/integrations/claude-cli.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts index e9e54bd..df412aa 100644 --- a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -4,7 +4,7 @@ import { IntegrationError } from '../../../../utils/errors'; export class ClaudeCLIProvider implements AIProvider { async complete(prompt: string, systemPrompt?: string): Promise { - const args = ['--print', '--no-session-persistence', prompt]; + const args = ['--print', prompt]; if (systemPrompt) args.unshift('--system-prompt', systemPrompt); let result; diff --git a/tests/integrations/claude-cli.test.ts b/tests/integrations/claude-cli.test.ts index b27e34b..2f87598 100644 --- a/tests/integrations/claude-cli.test.ts +++ b/tests/integrations/claude-cli.test.ts @@ -21,7 +21,7 @@ describe('ClaudeCLIProvider', () => { expect(result).toBe('hello world'); }); - it('always passes --print and --no-session-persistence', async () => { + it('always passes --print', async () => { mockExeca.mockResolvedValueOnce({ exitCode: 0, stdout: 'ok', stderr: '' }); const provider = new ClaudeCLIProvider(); @@ -30,8 +30,6 @@ describe('ClaudeCLIProvider', () => { const [cmd, args] = mockExeca.mock.calls[0] as [string, string[]]; expect(cmd).toBe('claude'); expect(args).toContain('--print'); - expect(args).not.toContain('--tools'); - expect(args).toContain('--no-session-persistence'); }); it('includes prompt as last arg', async () => { From bb07d16d3277166d5c85c06d7d8ca6e50c948d90 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 00:32:07 -0300 Subject: [PATCH 05/12] 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. --- .../providers/ai/implementations/claude-cli-ai-provider.ts | 6 +++--- tests/integrations/claude-cli.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts index df412aa..d6a9e07 100644 --- a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -4,12 +4,12 @@ import { IntegrationError } from '../../../../utils/errors'; export class ClaudeCLIProvider implements AIProvider { async complete(prompt: string, systemPrompt?: string): Promise { - const args = ['--print', prompt]; - if (systemPrompt) args.unshift('--system-prompt', systemPrompt); + const args: string[] = ['--print']; + if (systemPrompt) args.push('--system-prompt', systemPrompt); let result; try { - result = await execa('claude', args, { reject: false }); + result = await execa('claude', args, { input: prompt, reject: false }); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { throw new IntegrationError( diff --git a/tests/integrations/claude-cli.test.ts b/tests/integrations/claude-cli.test.ts index 2f87598..f03ffa8 100644 --- a/tests/integrations/claude-cli.test.ts +++ b/tests/integrations/claude-cli.test.ts @@ -32,14 +32,14 @@ describe('ClaudeCLIProvider', () => { expect(args).toContain('--print'); }); - it('includes prompt as last arg', async () => { + 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 [, args] = mockExeca.mock.calls[0] as [string, string[]]; - expect(args[args.length - 1]).toBe('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 () => { From 985a1f4608d2f1737323bbab1181424b1946f86e Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 01:39:32 -0300 Subject: [PATCH 06/12] 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. --- .../providers/ai/implementations/claude-cli-ai-provider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts index d6a9e07..d49c935 100644 --- a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -7,9 +7,13 @@ export class ClaudeCLIProvider implements AIProvider { const args: string[] = ['--print']; 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 }); + result = await execa('claude', args, { input: prompt, reject: false, env }); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { throw new IntegrationError( From 4c6a35988576cdeee5105aedc72e1133b4b2f2f8 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 02:02:33 -0300 Subject: [PATCH 07/12] 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. --- package.json | 1 + pnpm-lock.yaml | 131 +++++++++++++++++++++++++++++++++++++++++++++ src/commands/pr.ts | 7 ++- src/ui/prompts.ts | 5 ++ 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 1fb45d7..2caefb8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.78.0", "@clack/prompts": "^1.1.0", + "@inquirer/editor": "^5.0.8", "@notionhq/client": "^5.11.1", "@slack/web-api": "^7.14.1", "boxen": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 992fde7..da2d4bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 + '@inquirer/editor': + specifier: ^5.0.8 + version: 5.0.8(@types/node@22.19.13) '@notionhq/client': specifier: ^5.11.1 version: 5.11.1 @@ -303,6 +306,50 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/ansi@2.0.3': + resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/core@11.1.5': + resolution: {integrity: sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.0.8': + resolution: {integrity: sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@2.0.3': + resolution: {integrity: sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.3': + resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + + '@inquirer/type@4.0.3': + resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -669,6 +716,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -693,6 +743,10 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -865,6 +919,15 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -960,6 +1023,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1092,6 +1159,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1254,6 +1325,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1676,6 +1750,41 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@2.0.3': {} + + '@inquirer/core@11.1.5(@types/node@22.19.13)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@22.19.13) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.19.13 + + '@inquirer/editor@5.0.8(@types/node@22.19.13)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@22.19.13) + '@inquirer/external-editor': 2.0.3(@types/node@22.19.13) + '@inquirer/type': 4.0.3(@types/node@22.19.13) + optionalDependencies: + '@types/node': 22.19.13 + + '@inquirer/external-editor@2.0.3(@types/node@22.19.13)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 22.19.13 + + '@inquirer/figures@2.0.3': {} + + '@inquirer/type@4.0.3(@types/node@22.19.13)': + optionalDependencies: + '@types/node': 22.19.13 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2026,6 +2135,8 @@ snapshots: chalk@5.6.2: {} + chardet@2.1.1: {} + check-error@2.1.3: {} chokidar@4.0.3: @@ -2046,6 +2157,8 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 + cli-width@4.1.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -2242,6 +2355,16 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2334,6 +2457,10 @@ snapshots: human-signals@8.0.1: {} + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2432,6 +2559,8 @@ snapshots: ms@2.1.3: {} + mute-stream@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -2595,6 +2724,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + safer-buffer@2.1.2: {} + semver@7.7.4: {} shebang-command@2.0.0: 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/ui/prompts.ts b/src/ui/prompts.ts index a2ac973..3776d53 100644 --- a/src/ui/prompts.ts +++ b/src/ui/prompts.ts @@ -1,4 +1,5 @@ import * as clack from '@clack/prompts'; +import inquirerEditor from '@inquirer/editor'; export function intro(title: string): void { clack.intro(title); @@ -58,4 +59,8 @@ export async function select(opts: { return result as T; } +export async function editor(opts: { message: string; initialValue?: string }): Promise { + return inquirerEditor({ message: opts.message, default: opts.initialValue }); +} + export { clack }; From 689266b0a6d272c1e46ae5207c85bb4d9ad24404 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 02:02:55 -0300 Subject: [PATCH 08/12] 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. --- .../providers/ai/implementations/claude-cli-ai-provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts index d49c935..454f697 100644 --- a/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts +++ b/src/integrations/providers/ai/implementations/claude-cli-ai-provider.ts @@ -4,7 +4,7 @@ import { IntegrationError } from '../../../../utils/errors'; export class ClaudeCLIProvider implements AIProvider { async complete(prompt: string, systemPrompt?: string): Promise { - const args: string[] = ['--print']; + const args: string[] = ['--print', '--tools', '', '--no-session-persistence']; if (systemPrompt) args.push('--system-prompt', systemPrompt); const env = { ...process.env }; From 9884e4a87d1a42aa3b3af9e511a4d31a38659aec Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 02:08:27 -0300 Subject: [PATCH 09/12] feat: replace @inquirer/editor with $EDITOR-based multi-line prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 1 - pnpm-lock.yaml | 131 ---------------------------------------------- src/ui/prompts.ts | 22 +++++++- 3 files changed, 20 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index 2caefb8..1fb45d7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "dependencies": { "@anthropic-ai/sdk": "^0.78.0", "@clack/prompts": "^1.1.0", - "@inquirer/editor": "^5.0.8", "@notionhq/client": "^5.11.1", "@slack/web-api": "^7.14.1", "boxen": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da2d4bd..992fde7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@clack/prompts': specifier: ^1.1.0 version: 1.1.0 - '@inquirer/editor': - specifier: ^5.0.8 - version: 5.0.8(@types/node@22.19.13) '@notionhq/client': specifier: ^5.11.1 version: 5.11.1 @@ -306,50 +303,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@2.0.3': - resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - - '@inquirer/core@11.1.5': - resolution: {integrity: sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/editor@5.0.8': - resolution: {integrity: sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@2.0.3': - resolution: {integrity: sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@2.0.3': - resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - - '@inquirer/type@4.0.3': - resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -716,9 +669,6 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -743,10 +693,6 @@ packages: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -919,15 +865,6 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1023,10 +960,6 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1159,10 +1092,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mute-stream@3.0.0: - resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} - engines: {node: ^20.17.0 || >=22.9.0} - mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1325,9 +1254,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1750,41 +1676,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@2.0.3': {} - - '@inquirer/core@11.1.5(@types/node@22.19.13)': - dependencies: - '@inquirer/ansi': 2.0.3 - '@inquirer/figures': 2.0.3 - '@inquirer/type': 4.0.3(@types/node@22.19.13) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.0 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - optionalDependencies: - '@types/node': 22.19.13 - - '@inquirer/editor@5.0.8(@types/node@22.19.13)': - dependencies: - '@inquirer/core': 11.1.5(@types/node@22.19.13) - '@inquirer/external-editor': 2.0.3(@types/node@22.19.13) - '@inquirer/type': 4.0.3(@types/node@22.19.13) - optionalDependencies: - '@types/node': 22.19.13 - - '@inquirer/external-editor@2.0.3(@types/node@22.19.13)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 22.19.13 - - '@inquirer/figures@2.0.3': {} - - '@inquirer/type@4.0.3(@types/node@22.19.13)': - optionalDependencies: - '@types/node': 22.19.13 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2135,8 +2026,6 @@ snapshots: chalk@5.6.2: {} - chardet@2.1.1: {} - check-error@2.1.3: {} chokidar@4.0.3: @@ -2157,8 +2046,6 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 - cli-width@4.1.0: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -2355,16 +2242,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - - fast-wrap-ansi@0.2.0: - dependencies: - fast-string-width: 3.0.2 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2457,10 +2334,6 @@ snapshots: human-signals@8.0.1: {} - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - ignore@5.3.2: {} ignore@7.0.5: {} @@ -2559,8 +2432,6 @@ snapshots: ms@2.1.3: {} - mute-stream@3.0.0: {} - mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -2724,8 +2595,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - safer-buffer@2.1.2: {} - semver@7.7.4: {} shebang-command@2.0.0: diff --git a/src/ui/prompts.ts b/src/ui/prompts.ts index 3776d53..2e4848b 100644 --- a/src/ui/prompts.ts +++ b/src/ui/prompts.ts @@ -1,5 +1,8 @@ +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'; -import inquirerEditor from '@inquirer/editor'; export function intro(title: string): void { clack.intro(title); @@ -60,7 +63,22 @@ export async function select(opts: { } export async function editor(opts: { message: string; initialValue?: string }): Promise { - return inquirerEditor({ message: opts.message, default: opts.initialValue }); + 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 }; From 30a54b59ed9e18ad26e9440062724565b6868975 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 02:15:30 -0300 Subject: [PATCH 10/12] 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 --- src/commands/config.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 62c709b..5d2bbe4 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -12,22 +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: 'anthropic-api', label: 'Anthropic API (use API key above)' }, - { value: 'claude-cli', label: 'Claude CLI (use local claude binary)' }, + { 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 ?? 'anthropic-api', - })) as GlobalConfig['aiProvider']; + 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?', @@ -134,7 +138,7 @@ async function runConfigWizard(existing: GlobalConfig | undefined): Promise Date: Tue, 10 Mar 2026 02:20:18 -0300 Subject: [PATCH 11/12] 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. --- src/commands/config.ts | 57 +++++++++++++++++++++--------------------- src/ui/prompts.ts | 15 +++++++++++ 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 5d2bbe4..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 { @@ -63,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, @@ -91,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 @@ -116,24 +135,6 @@ 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, diff --git a/src/ui/prompts.ts b/src/ui/prompts.ts index 2e4848b..2a52c57 100644 --- a/src/ui/prompts.ts +++ b/src/ui/prompts.ts @@ -62,6 +62,21 @@ 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'); From 12afb584a292d2ee4fadf4f8b1e80b076a520b93 Mon Sep 17 00:00:00 2001 From: Davi Rodrigues Date: Tue, 10 Mar 2026 02:45:35 -0300 Subject: [PATCH 12/12] 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 --- src/commands/pr.ts | 8 +++- src/commands/start.ts | 3 ++ src/commands/track.ts | 1 + src/config/schemas.ts | 1 + src/integrations/providers/ai/prompts.ts | 59 +++++++++++++++++------- 5 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/commands/pr.ts b/src/commands/pr.ts index a5a653c..dc0b0e0 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -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, ), ); 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 2a37900..fb7c457 100644 --- a/src/config/schemas.ts +++ b/src/config/schemas.ts @@ -129,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/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