diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..7cddcaef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,108 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Automaker +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request. + + - type: dropdown + id: feature-area + attributes: + label: Feature Area + description: Which area of Automaker does this feature relate to? + options: + - UI/UX (User Interface) + - Agent/AI + - Kanban Board + - Git/Worktree Management + - Project Management + - Settings/Configuration + - Documentation + - Performance + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to your workflow? + options: + - Nice to have + - Would improve my workflow + - Critical for my use case + default: 0 + validations: + required: true + + - type: textarea + id: problem-statement + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe the problem you're trying to solve. + placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see implemented. + placeholder: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you've considered. + placeholder: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific scenarios where this feature would be useful. + placeholder: | + 1. When working on... + 2. As a user who needs to... + 3. In situations where... + validations: + required: false + + - type: textarea + id: mockups + attributes: + label: Mockups/Screenshots + description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request. + placeholder: Drag and drop images here or paste image URLs + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, references, or examples about the feature request here. + placeholder: Any additional information that might be helpful... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this feature hasn't been requested already + required: true + - label: I have provided a clear description of the problem and proposed solution + required: true diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index ecdd46af..f8a31d81 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -22,6 +22,8 @@ import type { // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', 'PATH', 'HOME', 'SHELL', diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts index 20816bbc..ec35ca1b 100644 --- a/apps/server/src/routes/claude/index.ts +++ b/apps/server/src/routes/claude/index.ts @@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router { error: 'Authentication required', message: "Please run 'claude login' to authenticate", }); + } else if (message.includes('TRUST_PROMPT_PENDING')) { + // Trust prompt appeared but couldn't be auto-approved + res.status(200).json({ + error: 'Trust prompt pending', + message: + 'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.', + }); } else if (message.includes('timed out')) { res.status(200).json({ error: 'Command timed out', diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index dc7d7d6c..c6db10fc 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -1,5 +1,5 @@ /** - * POST /list-branches endpoint - List all local branches + * POST /list-branches endpoint - List all local branches and optionally remote branches * * Note: Git repository validation (isGitRepo, hasCommits) is handled by * the requireValidWorktree middleware in index.ts @@ -21,8 +21,9 @@ interface BranchInfo { export function createListBranchesHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, includeRemote = false } = req.body as { worktreePath: string; + includeRemote?: boolean; }; if (!worktreePath) { @@ -60,6 +61,55 @@ export function createListBranchesHandler() { }; }); + // Fetch remote branches if requested + if (includeRemote) { + try { + // Fetch latest remote refs (silently, don't fail if offline) + try { + await execAsync('git fetch --all --quiet', { + cwd: worktreePath, + timeout: 10000, // 10 second timeout + }); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // List remote branches + const { stdout: remoteBranchesOutput } = await execAsync( + 'git branch -r --format="%(refname:short)"', + { cwd: worktreePath } + ); + + const localBranchNames = new Set(branches.map((b) => b.name)); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((b) => b.trim()) + .forEach((name) => { + // Remove any surrounding quotes + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanName.includes('/HEAD')) return; + + // Only add remote branches if a branch with the exact same name isn't already + // in the list. This avoids duplicates if a local branch is named like a remote one. + // Note: We intentionally include remote branches even when a local branch with the + // same base name exists (e.g., show "origin/main" even if local "main" exists), + // since users need to select remote branches as PR base targets. + if (!localBranchNames.has(cleanName)) { + branches.push({ + name: cleanName, // Keep full name like "origin/main" + isCurrent: false, + isRemote: true, + }); + } + }); + } catch { + // Ignore errors fetching remote branches - return local branches only + } + } + // Get ahead/behind count for current branch let aheadCount = 0; let behindCount = 0; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index c9000582..64dceb6a 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -49,13 +49,11 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses platform-specific PTY implementation + * Uses node-pty on all platforms for consistency */ private executeClaudeUsageCommand(): Promise { - if (this.isWindows || this.isLinux) { - return this.executeClaudeUsageCommandPty(); - } - return this.executeClaudeUsageCommandMac(); + // Use node-pty on all platforms - it's more reliable than expect on macOS + return this.executeClaudeUsageCommandPty(); } /** @@ -67,24 +65,36 @@ export class ClaudeUsageService { let stderr = ''; let settled = false; - // Use a simple working directory (home or tmp) - const workingDirectory = process.env.HOME || '/tmp'; + // Use current working directory - likely already trusted by Claude CLI + const workingDirectory = process.cwd(); // Use 'expect' with an inline script to run claude /usage with a PTY - // Wait for "Current session" header, then wait for full output before exiting + // Running from cwd which should already be trusted const expectScript = ` - set timeout 20 + set timeout 30 spawn claude /usage + + # Wait for usage data or handle trust prompt if needed expect { - "Current session" { - sleep 2 - send "\\x1b" + -re "Ready to code|permission to work|Do you want to work" { + # Trust prompt appeared - send Enter to approve + sleep 1 + send "\\r" + exp_continue } - "Esc to cancel" { + "Current session" { + # Usage data appeared - wait for full output, then exit sleep 3 send "\\x1b" } - timeout {} + "% left" { + # Usage percentage appeared + sleep 3 + send "\\x1b" + } + timeout { + send "\\x1b" + } eof {} } expect eof @@ -158,10 +168,10 @@ export class ClaudeUsageService { let output = ''; let settled = false; let hasSeenUsageData = false; + let hasSeenTrustPrompt = false; - const workingDirectory = this.isWindows - ? process.env.USERPROFILE || os.homedir() || 'C:\\' - : os.tmpdir(); + // Use current working directory (project dir) - most likely already trusted by Claude CLI + const workingDirectory = process.cwd(); // Use platform-appropriate shell and command const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; @@ -206,6 +216,13 @@ export class ClaudeUsageService { // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); + } else if (hasSeenTrustPrompt) { + // Trust prompt was shown but we couldn't auto-approve it + reject( + new Error( + 'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.' + ) + ); } else { reject( new Error( @@ -269,10 +286,18 @@ export class ClaudeUsageService { }, 3000); } - // Handle Trust Dialog: "Do you want to work in this folder?" - // Since we are running in os.tmpdir(), it is safe to approve. - if (!hasApprovedTrust && cleanOutput.includes('Do you want to work in this folder?')) { + // Handle Trust Dialog - multiple variants: + // - "Do you want to work in this folder?" + // - "Ready to code here?" / "I'll need permission to work with your files" + // Since we are running in cwd (project dir), it is safe to approve. + if ( + !hasApprovedTrust && + (cleanOutput.includes('Do you want to work in this folder?') || + cleanOutput.includes('Ready to code here') || + cleanOutput.includes('permission to work with your files')) + ) { hasApprovedTrust = true; + hasSeenTrustPrompt = true; // Wait a tiny bit to ensure prompt is ready, then send Enter setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index f107c4f4..b3d2df79 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -12,6 +12,8 @@ describe('claude-provider.ts', () => { vi.clearAllMocks(); provider = new ClaudeProvider(); delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; }); describe('getName', () => { @@ -267,6 +269,93 @@ describe('claude-provider.ts', () => { }); }); + describe('environment variable passthrough', () => { + afterEach(() => { + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + it('should pass ANTHROPIC_BASE_URL to SDK env', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://custom.example.com/v1', + }), + }), + }); + }); + + it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => { + process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_AUTH_TOKEN: 'custom-auth-token', + }), + }), + }); + }); + + it('should pass both custom endpoint vars together', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com'; + process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + }), + }), + }); + }); + }); + describe('getAvailableModels', () => { it('should return 4 Claude models', () => { const models = provider.getAvailableModels(); diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 227f16e1..d51e316c 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store'; const ERROR_CODES = { API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE', AUTH_ERROR: 'AUTH_ERROR', + TRUST_PROMPT: 'TRUST_PROMPT', UNKNOWN: 'UNKNOWN', } as const; @@ -55,8 +56,12 @@ export function ClaudeUsagePopover() { } const data = await api.claude.getUsage(); if ('error' in data) { + // Detect trust prompt error + const isTrustPrompt = + data.error === 'Trust prompt pending' || + (data.message && data.message.includes('folder permission')); setError({ - code: ERROR_CODES.AUTH_ERROR, + code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR, message: data.message || data.error, }); return; @@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {

{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( 'Ensure the Electron bridge is running or restart the app' + ) : error.code === ERROR_CODES.TRUST_PROMPT ? ( + <> + Run claude in your + terminal and approve access to continue + ) : ( <> Make sure Claude CLI is installed and authenticated via{' '} diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index 32a6315d..39a7b652 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -1,9 +1,105 @@ -import { useEffect, useRef, useState } from 'react'; -import { Edit2, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState, memo } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useAppStore } from '@/store/app-store'; +import { type ThemeMode, useAppStore } from '@/store/app-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import type { Project } from '@/lib/electron'; +import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants'; +import { useThemePreview } from '@/components/layout/sidebar/hooks'; + +// Constants for z-index values +const Z_INDEX = { + CONTEXT_MENU: 100, + THEME_SUBMENU: 101, +} as const; + +// Theme option type - using ThemeMode for type safety +interface ThemeOption { + value: ThemeMode; + label: string; + icon: LucideIcon; + color: string; +} + +// Reusable theme button component to avoid duplication (DRY principle) +interface ThemeButtonProps { + option: ThemeOption; + isSelected: boolean; + onPointerEnter: () => void; + onPointerLeave: (e: React.PointerEvent) => void; + onClick: () => void; +} + +const ThemeButton = memo(function ThemeButton({ + option, + isSelected, + onPointerEnter, + onPointerLeave, + onClick, +}: ThemeButtonProps) { + const Icon = option.icon; + return ( + + ); +}); + +// Reusable theme column component +interface ThemeColumnProps { + title: string; + icon: LucideIcon; + themes: ThemeOption[]; + selectedTheme: ThemeMode | null; + onPreviewEnter: (value: ThemeMode) => void; + onPreviewLeave: (e: React.PointerEvent) => void; + onSelect: (value: ThemeMode) => void; +} + +const ThemeColumn = memo(function ThemeColumn({ + title, + icon: Icon, + themes, + selectedTheme, + onPreviewEnter, + onPreviewLeave, + onSelect, +}: ThemeColumnProps) { + return ( +

+
+ + {title} +
+
+ {themes.map((option) => ( + onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + onClick={() => onSelect(option.value)} + /> + ))} +
+
+ ); +}); interface ProjectContextMenuProps { project: Project; @@ -19,18 +115,30 @@ export function ProjectContextMenu({ onEdit, }: ProjectContextMenuProps) { const menuRef = useRef(null); - const { moveProjectToTrash } = useAppStore(); + const { + moveProjectToTrash, + theme: globalTheme, + setTheme, + setProjectTheme, + setPreviewTheme, + } = useAppStore(); const [showRemoveDialog, setShowRemoveDialog] = useState(false); + const [showThemeSubmenu, setShowThemeSubmenu] = useState(false); + const themeSubmenuRef = useRef(null); + + const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme }); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setPreviewTheme(null); onClose(); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { + setPreviewTheme(null); onClose(); } }; @@ -42,7 +150,7 @@ export function ProjectContextMenu({ document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; - }, [onClose]); + }, [onClose, setPreviewTheme]); const handleEdit = () => { onEdit(project); @@ -52,6 +160,17 @@ export function ProjectContextMenu({ setShowRemoveDialog(true); }; + const handleThemeSelect = (value: ThemeMode | '') => { + setPreviewTheme(null); + if (value !== '') { + setTheme(value); + } else { + setTheme(globalTheme); + } + setProjectTheme(project.id, value === '' ? null : value); + setShowThemeSubmenu(false); + }; + const handleConfirmRemove = () => { moveProjectToTrash(project.id); onClose(); @@ -62,7 +181,7 @@ export function ProjectContextMenu({
@@ -88,6 +208,98 @@ export function ProjectContextMenu({ Edit Name & Icon + {/* Theme Submenu Trigger */} +
setShowThemeSubmenu(true)} + onMouseLeave={() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }} + > + + + {/* Theme Submenu */} + {showThemeSubmenu && ( +
+
+ {/* Use Global Option */} + + +
+ + {/* Two Column Layout - Using reusable ThemeColumn component */} +
+ + +
+
+
+ )} +
+
-
+
- setBaseBranch(e.target.value)} - className="font-mono text-sm" + onChange={setBaseBranch} + branches={branches} + placeholder="Select base branch..." + disabled={isLoadingBranches} + data-testid="base-branch-autocomplete" />
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 2ce7f6a7..f08e7620 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1746,8 +1746,8 @@ export class HttpApiClient implements ElectronAPI { pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }), checkoutBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/checkout-branch', { worktreePath, branchName }), - listBranches: (worktreePath: string) => - this.post('/api/worktree/list-branches', { worktreePath }), + listBranches: (worktreePath: string, includeRemote?: boolean) => + this.post('/api/worktree/list-branches', { worktreePath, includeRemote }), switchBranch: (worktreePath: string, branchName: string) => this.post('/api/worktree/switch-branch', { worktreePath, branchName }), openInEditor: (worktreePath: string, editorCommand?: string) => diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 23814c18..94a033f5 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -858,8 +858,11 @@ export interface WorktreeAPI { code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; }>; - // List all local branches - listBranches: (worktreePath: string) => Promise<{ + // List branches (local and optionally remote) + listBranches: ( + worktreePath: string, + includeRemote?: boolean + ) => Promise<{ success: boolean; result?: { currentBranch: string; diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 153f5122..2af347c7 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -47,6 +47,13 @@ fi chown -R automaker:automaker /home/automaker/.cache/opencode chmod -R 700 /home/automaker/.cache/opencode +# Ensure npm cache directory exists with correct permissions +# This is needed for using npx to run MCP servers +if [ ! -d "/home/automaker/.npm" ]; then + mkdir -p /home/automaker/.npm +fi +chown -R automaker:automaker /home/automaker/.npm + # If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file # On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage # The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent