Skip to content
Open
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
15 changes: 13 additions & 2 deletions src/lib/LoomManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
130 changes: 129 additions & 1 deletion src/utils/claude.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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)
})
})
})
57 changes: 57 additions & 0 deletions src/utils/claude.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<void> {
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<string, unknown> = {}
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<string, Record<string, unknown>>

// 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'}`)
}
}