diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index 677b9f0..7d903bf 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -13,7 +13,7 @@ import { SettingsManager } from './SettingsManager.js' import { MetadataManager, type WriteMetadataInput } from './MetadataManager.js' import { branchExists, executeGitCommand, ensureRepositoryHasCommits, extractIssueNumber, isFileTrackedByGit, extractPRNumber, PLACEHOLDER_COMMIT_PREFIX, pushBranchToRemote, GitCommandError, fetchOrigin } from '../utils/git.js' import { GitHubService } from './GitHubService.js' -import { generateRandomSessionId } from '../utils/claude.js' +import { generateRandomSessionId, acceptClaudeTrustDialog } from '../utils/claude.js' import { installDependencies } from '../utils/package-manager.js' import { generateColorFromBranchName, selectDistinctColor, hexToRgb, type ColorData } from '../utils/color.js' import { detectDarkMode } from '../utils/terminal.js' @@ -202,6 +202,11 @@ export class LoomManager { } } + // 10.1. Pre-accept Claude trust dialog if Claude is enabled + if (input.options?.enableClaude !== false) { + await acceptClaudeTrustDialog(worktreePath) + } + // 10.5. Handle github-draft-pr mode - push branch and create draft PR let draftPrNumber: number | undefined = undefined let draftPrUrl: string | undefined = undefined @@ -1321,8 +1326,14 @@ export class LoomManager { } } - // 7. Launch components (same as new worktree) + // 6.5. Pre-accept Claude trust dialog if Claude is enabled const enableClaude = input.options?.enableClaude !== false + if (enableClaude) { + await acceptClaudeTrustDialog(worktreePath) + } + + // 7. Launch components (same as new worktree) + // Note: enableClaude is already defined above const enableCode = input.options?.enableCode !== false const enableDevServer = input.options?.enableDevServer !== false const enableTerminal = input.options?.enableTerminal ?? false diff --git a/src/utils/claude.test.ts b/src/utils/claude.test.ts index a95598b..88c2998 100644 --- a/src/utils/claude.test.ts +++ b/src/utils/claude.test.ts @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { execa } from 'execa' import { existsSync } from 'node:fs' -import { detectClaudeCli, getClaudeVersion, launchClaude, generateBranchName, launchClaudeInNewTerminalWindow, generateDeterministicSessionId, generateRandomSessionId } from './claude.js' +import { detectClaudeCli, getClaudeVersion, launchClaude, generateBranchName, launchClaudeInNewTerminalWindow, generateDeterministicSessionId, generateRandomSessionId, acceptClaudeTrustDialog } from './claude.js' +import fse from 'fs-extra' +import path from 'node:path' +import os from 'node:os' import { logger } from './logger.js' +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MockExecaReturn = any + const mockLogger = { debug: vi.fn(), warn: vi.fn(), @@ -2206,4 +2212,126 @@ describe('claude utils', () => { expect(result).toBe('feat/issue-mark-1__nextjs-vercel') }) }) + + describe('acceptClaudeTrustDialog', () => { + let tmpDir: string + const originalEnv = process.env.CLAUDE_CONFIG_DIR + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'claude-test-')) + process.env.CLAUDE_CONFIG_DIR = tmpDir + }) + + afterEach(async () => { + if (originalEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalEnv + } + await fse.remove(tmpDir) + }) + + it('should create .claude.json with trust flag when file does not exist', async () => { + const worktreePath = '/tmp/test-worktree' + + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '', + exitCode: 0, + } as MockExecaReturn) + + await acceptClaudeTrustDialog(worktreePath) + + const claudeJson = await fse.readJson(path.join(tmpDir, '.claude.json')) + expect(claudeJson.projects[worktreePath].hasTrustDialogAccepted).toBe(true) + }) + + it('should add project entry to existing .claude.json without clobbering', async () => { + const existingPath = '/tmp/existing-project' + const newPath = '/tmp/new-worktree' + + // Pre-create .claude.json with existing project + await fse.writeJson(path.join(tmpDir, '.claude.json'), { + projects: { + [existingPath]: { hasTrustDialogAccepted: true, someOtherField: 'value' }, + }, + }, { spaces: 2 }) + + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '', + exitCode: 0, + } as MockExecaReturn) + + await acceptClaudeTrustDialog(newPath) + + const claudeJson = await fse.readJson(path.join(tmpDir, '.claude.json')) + // New project added + expect(claudeJson.projects[newPath].hasTrustDialogAccepted).toBe(true) + // Existing project preserved + expect(claudeJson.projects[existingPath].hasTrustDialogAccepted).toBe(true) + expect(claudeJson.projects[existingPath].someOtherField).toBe('value') + }) + + it('should handle existing entry for same path by setting trust flag', async () => { + const worktreePath = '/tmp/existing-worktree' + + // Pre-create with existing data for the same path + await fse.writeJson(path.join(tmpDir, '.claude.json'), { + projects: { + [worktreePath]: { someExistingData: 42 }, + }, + }, { spaces: 2 }) + + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '', + exitCode: 0, + } as MockExecaReturn) + + await acceptClaudeTrustDialog(worktreePath) + + const claudeJson = await fse.readJson(path.join(tmpDir, '.claude.json')) + expect(claudeJson.projects[worktreePath].hasTrustDialogAccepted).toBe(true) + expect(claudeJson.projects[worktreePath].someExistingData).toBe(42) + }) + + it('should call claude CLI with correct flags and cwd', async () => { + const worktreePath = '/tmp/test-worktree' + + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '', + exitCode: 0, + } as MockExecaReturn) + + await acceptClaudeTrustDialog(worktreePath) + + expect(execa).toHaveBeenCalledWith( + 'claude', + ['--dangerously-skip-permissions', '--no-session-persistence', '--print'], + expect.objectContaining({ + cwd: worktreePath, + timeout: 10000, + reject: false, + }) + ) + }) + + it('should handle .claude.json with no projects key', async () => { + const worktreePath = '/tmp/test-worktree' + + // Pre-create .claude.json with unrelated data + await fse.writeJson(path.join(tmpDir, '.claude.json'), { + someOtherConfig: true, + }, { spaces: 2 }) + + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '', + exitCode: 0, + } as MockExecaReturn) + + await acceptClaudeTrustDialog(worktreePath) + + const claudeJson = await fse.readJson(path.join(tmpDir, '.claude.json')) + expect(claudeJson.someOtherConfig).toBe(true) + expect(claudeJson.projects[worktreePath].hasTrustDialogAccepted).toBe(true) + }) + }) }) diff --git a/src/utils/claude.ts b/src/utils/claude.ts index 467ab61..d3c481f 100644 --- a/src/utils/claude.ts +++ b/src/utils/claude.ts @@ -1,7 +1,10 @@ import { execa } from 'execa' import { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { join } from 'node:path' import { createHash, randomUUID } from 'node:crypto' +import fs from 'fs-extra' import { logger } from './logger.js' import { getLogger } from './logger-context.js' import { openTerminalWindow } from './terminal.js' @@ -581,3 +584,57 @@ function isValidBranchName(name: string, issueNumber: string | number): boolean const pattern = new RegExp(`^(feat|fix|docs|refactor|test|chore)/issue-${issueNumber}__[a-z0-9-]+$`, 'i') return pattern.test(name) && name.length <= 50 } + +/** + * Pre-accept Claude Code trust dialog for a worktree path + * This allows Claude to launch without the interactive trust confirmation + * + * Uses CLAUDE_CONFIG_DIR env var for config directory, falling back to os.homedir() + * + * @param worktreePath - The path to the worktree to trust + */ +export async function acceptClaudeTrustDialog(worktreePath: string): Promise { + const configDir = process.env.CLAUDE_CONFIG_DIR ?? os.homedir() + const claudeJsonPath = path.join(configDir, '.claude.json') + + // 1. Run claude command to register the project path (will fail but updates config) + try { + await execa('claude', [ + '--dangerously-skip-permissions', + '--no-session-persistence', + '--print' + ], { + cwd: worktreePath, + timeout: 10000, + reject: false, // Don't throw on non-zero exit + }) + } catch { + // Expected to fail - we just need it to register the path + } + + // 2. Read .claude.json and set hasTrustDialogAccepted + try { + let claudeJson: Record = {} + if (await fs.pathExists(claudeJsonPath)) { + claudeJson = await fs.readJson(claudeJsonPath) + } + + // Ensure projects object exists (structure: { projects: { ... } }) + if (!claudeJson.projects || typeof claudeJson.projects !== 'object') { + claudeJson.projects = {} + } + const projects = claudeJson.projects as Record> + + // Ensure project entry exists + projects[worktreePath] ??= {} + + // Set trust flag + projects[worktreePath].hasTrustDialogAccepted = true + + // Write back + await fs.writeJson(claudeJsonPath, claudeJson, { spaces: 2 }) + getLogger().debug(`Accepted Claude trust dialog for: ${worktreePath}`) + } catch (error) { + getLogger().warn(`Failed to accept Claude trust dialog: ${error instanceof Error ? error.message : 'Unknown error'}`) + } +}