From d6b6d1d0a2b5f9820e5dc38123fcc51231a28e8c Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Wed, 11 Feb 2026 08:32:13 -0800 Subject: [PATCH 1/2] feat: accept claude trust dialog Pre-accept the Claude Code trust dialog for new and resumed looms, allowing Claude to launch without interactive trust confirmation. Co-Authored-By: Claude Opus 4.6 --- src/lib/LoomManager.ts | 66 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index 677b9f0e..8709e309 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -1,6 +1,8 @@ import path from 'path' +import os from 'os' import fs from 'fs-extra' import fg from 'fast-glob' +import { execa } from 'execa' import { GitWorktreeManager } from './GitWorktreeManager.js' import type { IssueTracker } from './IssueTracker.js' import type { BranchNamingService } from './BranchNamingService.js' @@ -202,6 +204,11 @@ export class LoomManager { } } + // 10.1. Pre-accept Claude trust dialog if Claude is enabled + if (input.options?.enableClaude !== false) { + await this.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 @@ -1143,6 +1150,57 @@ export class LoomManager { // The colorTerminal setting is passed through to launch options } + /** + * Pre-accept Claude Code trust dialog for a worktree path + * This allows Claude to launch without the interactive trust confirmation + * + * @param worktreePath - The path to the worktree to trust + */ + private async acceptClaudeTrustDialog(worktreePath: string): Promise { + const claudeJsonPath = path.join(os.homedir(), '.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'}`) + } + } + /** * Map worktrees to loom objects * Reads loom metadata from MetadataManager with branch name parsing as fallback @@ -1321,8 +1379,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 this.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 From 213783936f983f15c81009b9261eb13ca3b964a2 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Wed, 11 Feb 2026 10:35:54 -0800 Subject: [PATCH 2/2] test: extract acceptClaudeTrustDialog to utils/claude.ts and add tests Extract the private acceptClaudeTrustDialog method from LoomManager into a standalone exported function in src/utils/claude.ts with CLAUDE_CONFIG_DIR env var support for testability. Add 5 tests covering file creation, existing project preservation, same-path updates, CLI flag verification, and missing projects key handling. Co-Authored-By: Claude Opus 4.6 --- src/lib/LoomManager.ts | 59 +----------------- src/utils/claude.test.ts | 130 ++++++++++++++++++++++++++++++++++++++- src/utils/claude.ts | 57 +++++++++++++++++ 3 files changed, 189 insertions(+), 57 deletions(-) diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index 8709e309..7d903bf1 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -1,8 +1,6 @@ import path from 'path' -import os from 'os' import fs from 'fs-extra' import fg from 'fast-glob' -import { execa } from 'execa' import { GitWorktreeManager } from './GitWorktreeManager.js' import type { IssueTracker } from './IssueTracker.js' import type { BranchNamingService } from './BranchNamingService.js' @@ -15,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' @@ -206,7 +204,7 @@ export class LoomManager { // 10.1. Pre-accept Claude trust dialog if Claude is enabled if (input.options?.enableClaude !== false) { - await this.acceptClaudeTrustDialog(worktreePath) + await acceptClaudeTrustDialog(worktreePath) } // 10.5. Handle github-draft-pr mode - push branch and create draft PR @@ -1150,57 +1148,6 @@ export class LoomManager { // The colorTerminal setting is passed through to launch options } - /** - * Pre-accept Claude Code trust dialog for a worktree path - * This allows Claude to launch without the interactive trust confirmation - * - * @param worktreePath - The path to the worktree to trust - */ - private async acceptClaudeTrustDialog(worktreePath: string): Promise { - const claudeJsonPath = path.join(os.homedir(), '.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'}`) - } - } - /** * Map worktrees to loom objects * Reads loom metadata from MetadataManager with branch name parsing as fallback @@ -1382,7 +1329,7 @@ export class LoomManager { // 6.5. Pre-accept Claude trust dialog if Claude is enabled const enableClaude = input.options?.enableClaude !== false if (enableClaude) { - await this.acceptClaudeTrustDialog(worktreePath) + await acceptClaudeTrustDialog(worktreePath) } // 7. Launch components (same as new worktree) diff --git a/src/utils/claude.test.ts b/src/utils/claude.test.ts index a95598bd..88c29987 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 467ab614..d3c481f1 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'}`) + } +}