From 5bd2b705dc9d8ebcbe71d4bbb90292b9c424abc3 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:03:43 +0400 Subject: [PATCH 01/14] feat: add Claude usage tracking via CLI Adds a Claude usage tracking feature that displays session, weekly, and Sonnet usage stats. Uses the Claude CLI's /usage command to fetch data (no API key required). Features: - Usage popover in board header showing session, weekly, and Sonnet limits - Progress bars with color-coded status (green/orange/red) - Auto-refresh with configurable interval - Caching of usage data with stale indicator - Settings section for refresh interval configuration Server: - ClaudeUsageService: Executes Claude CLI via PTY (expect) to fetch usage - New /api/claude/usage endpoint UI: - ClaudeUsagePopover component with usage cards - ClaudeUsageSection in settings for configuration - Integration with app store for persistence --- apps/server/src/index.ts | 2 + apps/server/src/routes/claude/index.ts | 44 +++ apps/server/src/routes/claude/types.ts | 35 ++ .../src/services/claude-usage-service.ts | 358 ++++++++++++++++++ .../src/components/claude-usage-popover.tsx | 309 +++++++++++++++ .../views/board-view/board-header.tsx | 4 + .../ui/src/components/views/settings-view.tsx | 14 +- .../api-keys/claude-usage-section.tsx | 75 ++++ apps/ui/src/lib/electron.ts | 30 ++ apps/ui/src/lib/http-api-client.ts | 5 + apps/ui/src/store/app-store.ts | 81 ++++ 11 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/routes/claude/index.ts create mode 100644 apps/server/src/routes/claude/types.ts create mode 100644 apps/server/src/services/claude-usage-service.ts create mode 100644 apps/ui/src/components/claude-usage-popover.tsx create mode 100644 apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0cbece15..51e92e80 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -44,6 +44,7 @@ import { AutoModeService } from "./services/auto-mode-service.js"; import { getTerminalService } from "./services/terminal-service.js"; import { SettingsService } from "./services/settings-service.js"; import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js"; +import { createClaudeRoutes } from "./routes/claude/index.js"; // Load environment variables dotenv.config(); @@ -141,6 +142,7 @@ app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); app.use("/api/terminal", createTerminalRoutes()); app.use("/api/settings", createSettingsRoutes(settingsService)); +app.use("/api/claude", createClaudeRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts new file mode 100644 index 00000000..8f359d5f --- /dev/null +++ b/apps/server/src/routes/claude/index.ts @@ -0,0 +1,44 @@ +import { Router, Request, Response } from "express"; +import { ClaudeUsageService } from "../../services/claude-usage-service.js"; + +export function createClaudeRoutes(): Router { + const router = Router(); + const service = new ClaudeUsageService(); + + // Get current usage (fetches from Claude CLI) + router.get("/usage", async (req: Request, res: Response) => { + try { + // Check if Claude CLI is available first + const isAvailable = await service.isAvailable(); + if (!isAvailable) { + res.status(503).json({ + error: "Claude CLI not found", + message: "Please install Claude Code CLI and run 'claude login' to authenticate" + }); + return; + } + + const usage = await service.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + + if (message.includes("Authentication required") || message.includes("token_expired")) { + res.status(401).json({ + error: "Authentication required", + message: "Please run 'claude login' to authenticate" + }); + } else if (message.includes("timed out")) { + res.status(504).json({ + error: "Command timed out", + message: "The Claude CLI took too long to respond" + }); + } else { + console.error("Error fetching usage:", error); + res.status(500).json({ error: message }); + } + } + }); + + return router; +} diff --git a/apps/server/src/routes/claude/types.ts b/apps/server/src/routes/claude/types.ts new file mode 100644 index 00000000..d7baa0c5 --- /dev/null +++ b/apps/server/src/routes/claude/types.ts @@ -0,0 +1,35 @@ +/** + * Claude Usage types for CLI-based usage tracking + */ + +export type ClaudeUsage = { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; // ISO date string + sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)" + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; // ISO date string + weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" + + opusWeeklyTokensUsed: number; + opusWeeklyPercentage: number; + opusResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; + + lastUpdated: string; // ISO date string + userTimezone: string; +}; + +export type ClaudeStatus = { + indicator: { + color: "green" | "yellow" | "orange" | "red" | "gray"; + }; + description: string; +}; diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts new file mode 100644 index 00000000..a164a46d --- /dev/null +++ b/apps/server/src/services/claude-usage-service.ts @@ -0,0 +1,358 @@ +import { spawn } from "child_process"; +import { ClaudeUsage } from "../routes/claude/types.js"; + +/** + * Claude Usage Service + * + * Fetches usage data by executing the Claude CLI's /usage command. + * This approach doesn't require any API keys - it relies on the user + * having already authenticated via `claude login`. + * + * Based on ClaudeBar's implementation approach. + */ +export class ClaudeUsageService { + private claudeBinary = "claude"; + private timeout = 30000; // 30 second timeout + + /** + * Check if Claude CLI is available on the system + */ + async isAvailable(): Promise { + return new Promise((resolve) => { + const proc = spawn("which", [this.claudeBinary]); + proc.on("close", (code) => { + resolve(code === 0); + }); + proc.on("error", () => { + resolve(false); + }); + }); + } + + /** + * Fetch usage data by executing the Claude CLI + */ + async fetchUsageData(): Promise { + const output = await this.executeClaudeUsageCommand(); + return this.parseUsageOutput(output); + } + + /** + * Execute the claude /usage command and return the output + * Uses 'expect' to provide a pseudo-TTY since claude requires one + */ + private executeClaudeUsageCommand(): Promise { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + let settled = false; + + // Use a simple working directory (home or tmp) + const workingDirectory = process.env.HOME || "/tmp"; + + // 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 + const expectScript = ` + set timeout 20 + spawn claude /usage + expect { + "Current session" { + sleep 2 + send "\\x1b" + } + "Esc to cancel" { + sleep 3 + send "\\x1b" + } + timeout {} + eof {} + } + expect eof + `; + + const proc = spawn("expect", ["-c", expectScript], { + cwd: workingDirectory, + env: { + ...process.env, + TERM: "xterm-256color", + }, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + proc.kill(); + reject(new Error("Command timed out")); + } + }, this.timeout); + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if (stdout.includes("token_expired") || stdout.includes("authentication_error") || + stderr.includes("token_expired") || stderr.includes("authentication_error")) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + // Even if exit code is non-zero, we might have useful output + if (stdout.trim()) { + resolve(stdout); + } else if (code !== 0) { + reject(new Error(stderr || `Command exited with code ${code}`)); + } else { + reject(new Error("No output from claude command")); + } + }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + if (!settled) { + settled = true; + reject(new Error(`Failed to execute claude: ${err.message}`)); + } + }); + }); + } + + /** + * Strip ANSI escape codes from text + */ + private stripAnsiCodes(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ""); + } + + /** + * Parse the Claude CLI output to extract usage information + * + * Expected output format: + * ``` + * Claude Code v1.0.27 + * + * Current session + * ████████████████░░░░ 65% left + * Resets in 2h 15m + * + * Current week (all models) + * ██████████░░░░░░░░░░ 35% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * + * Current week (Opus) + * ████████████████████ 80% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * ``` + */ + private parseUsageOutput(rawOutput: string): ClaudeUsage { + const output = this.stripAnsiCodes(rawOutput); + const lines = output.split("\n").map(l => l.trim()).filter(l => l); + + // Parse session usage + const sessionData = this.parseSection(lines, "Current session", "session"); + + // Parse weekly usage (all models) + const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly"); + + // Parse Sonnet/Opus usage - try different labels + let opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus"); + if (opusData.percentage === 0) { + opusData = this.parseSection(lines, "Current week (Sonnet)", "opus"); + } + if (opusData.percentage === 0) { + opusData = this.parseSection(lines, "Current week (Opus)", "opus"); + } + + return { + sessionTokensUsed: 0, // Not available from CLI + sessionLimit: 0, // Not available from CLI + sessionPercentage: sessionData.percentage, + sessionResetTime: sessionData.resetTime, + sessionResetText: sessionData.resetText, + + weeklyTokensUsed: 0, // Not available from CLI + weeklyLimit: 0, // Not available from CLI + weeklyPercentage: weeklyData.percentage, + weeklyResetTime: weeklyData.resetTime, + weeklyResetText: weeklyData.resetText, + + opusWeeklyTokensUsed: 0, // Not available from CLI + opusWeeklyPercentage: opusData.percentage, + opusResetText: opusData.resetText, + + costUsed: null, // Not available from CLI + costLimit: null, + costCurrency: null, + + lastUpdated: new Date().toISOString(), + userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + } + + /** + * Parse a section of the usage output to extract percentage and reset time + */ + private parseSection(lines: string[], sectionLabel: string, type: string): { percentage: number; resetTime: string; resetText: string } { + let percentage = 0; + let resetTime = this.getDefaultResetTime(type); + let resetText = ""; + + // Find the LAST occurrence of the section (terminal output has multiple screen refreshes) + let sectionIndex = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].toLowerCase().includes(sectionLabel.toLowerCase())) { + sectionIndex = i; + break; + } + } + + if (sectionIndex === -1) { + return { percentage, resetTime, resetText }; + } + + // Look at the lines following the section header (within a window of 5 lines) + const searchWindow = lines.slice(sectionIndex, sectionIndex + 5); + + for (const line of searchWindow) { + // Extract percentage - look for patterns like "65% left" or "35% used" + const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); + if (percentMatch) { + const value = parseInt(percentMatch[1], 10); + const isUsed = percentMatch[2].toLowerCase() === "used"; + // Convert "left" to "used" percentage (our UI shows % used) + percentage = isUsed ? value : (100 - value); + } + + // Extract reset time + if (line.toLowerCase().includes("reset")) { + resetText = line; + } + } + + // Parse the reset time if we found one + if (resetText) { + resetTime = this.parseResetTime(resetText, type); + // Strip timezone like "(Asia/Dubai)" from the display text + resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, "").trim(); + } + + return { percentage, resetTime, resetText }; + } + + /** + * Parse reset time from text like "Resets in 2h 15m", "Resets 11am", or "Resets Dec 22 at 8pm" + */ + private parseResetTime(text: string, type: string): string { + const now = new Date(); + + // Try to parse duration format: "Resets in 2h 15m" or "Resets in 30m" + const durationMatch = text.match(/(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i); + if (durationMatch) { + let hours = 0; + let minutes = 0; + + if (durationMatch[1]) { + hours = parseInt(durationMatch[1], 10); + minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; + } else if (durationMatch[3]) { + minutes = parseInt(durationMatch[3], 10); + } + + const resetDate = new Date(now.getTime() + (hours * 60 + minutes) * 60 * 1000); + return resetDate.toISOString(); + } + + // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" + const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (simpleTimeMatch) { + let hours = parseInt(simpleTimeMatch[1], 10); + const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; + const ampm = simpleTimeMatch[3].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === "pm" && hours !== 12) { + hours += 12; + } else if (ampm === "am" && hours === 12) { + hours = 0; + } + + // Create date for today at specified time + const resetDate = new Date(now); + resetDate.setHours(hours, minutes, 0, 0); + + // If time has passed, use tomorrow + if (resetDate <= now) { + resetDate.setDate(resetDate.getDate() + 1); + } + return resetDate.toISOString(); + } + + // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + const dateMatch = text.match(/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (dateMatch) { + const monthName = dateMatch[1]; + const day = parseInt(dateMatch[2], 10); + let hours = parseInt(dateMatch[3], 10); + const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0; + const ampm = dateMatch[5].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === "pm" && hours !== 12) { + hours += 12; + } else if (ampm === "am" && hours === 12) { + hours = 0; + } + + // Parse month name + const months: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 + }; + const month = months[monthName.toLowerCase().substring(0, 3)]; + + if (month !== undefined) { + let year = now.getFullYear(); + // If the date appears to be in the past, assume next year + const resetDate = new Date(year, month, day, hours, minutes); + if (resetDate < now) { + resetDate.setFullYear(year + 1); + } + return resetDate.toISOString(); + } + } + + // Fallback to default + return this.getDefaultResetTime(type); + } + + /** + * Get default reset time based on usage type + */ + private getDefaultResetTime(type: string): string { + const now = new Date(); + + if (type === "session") { + // Session resets in ~5 hours + return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); + } else { + // Weekly resets on next Monday around noon + const result = new Date(now); + const currentDay = now.getDay(); + let daysUntilMonday = (1 + 7 - currentDay) % 7; + if (daysUntilMonday === 0) daysUntilMonday = 7; + result.setDate(result.getDate() + daysUntilMonday); + result.setHours(12, 59, 0, 0); + return result.toISOString(); + } + } +} diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx new file mode 100644 index 00000000..23182744 --- /dev/null +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useMemo } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { + RefreshCw, + AlertTriangle, + CheckCircle, + XCircle, + Clock, + ExternalLink, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getElectronAPI } from "@/lib/electron"; +import { useAppStore } from "@/store/app-store"; + +export function ClaudeUsagePopover() { + const { claudeRefreshInterval, claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = + useAppStore(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes + const isStale = useMemo(() => { + return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; + }, [claudeUsageLastUpdated]); + + const fetchUsage = async (isAutoRefresh = false) => { + if (!isAutoRefresh) setLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.claude) { + throw new Error("Claude API not available"); + } + const data = await api.claude.getUsage(); + if (data.error) { + throw new Error(data.message || data.error); + } + setClaudeUsage(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch usage"); + } finally { + if (!isAutoRefresh) setLoading(false); + } + }; + + // Auto-fetch on mount if data is stale + useEffect(() => { + if (isStale) { + fetchUsage(true); + } + }, []); + + useEffect(() => { + // Initial fetch when opened + if (open) { + if (!claudeUsage) { + fetchUsage(); + } else { + const now = Date.now(); + const stale = !claudeUsageLastUpdated || now - claudeUsageLastUpdated > 2 * 60 * 1000; + if (stale) { + fetchUsage(false); + } + } + } + + // Auto-refresh interval (only when open) + let intervalId: NodeJS.Timeout | null = null; + if (open && claudeRefreshInterval > 0) { + intervalId = setInterval(() => { + fetchUsage(true); + }, claudeRefreshInterval * 1000); + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [open]); + + // Derived status color/icon helper + const getStatusInfo = (percentage: number) => { + if (percentage >= 80) + return { color: "text-red-500", icon: XCircle, bg: "bg-red-500" }; + if (percentage >= 50) + return { color: "text-orange-500", icon: AlertTriangle, bg: "bg-orange-500" }; + return { color: "text-green-500", icon: CheckCircle, bg: "bg-green-500" }; + }; + + // Helper component for the progress bar + const ProgressBar = ({ + percentage, + colorClass, + }: { + percentage: number; + colorClass: string; + }) => ( +
+
+
+ ); + + const UsageCard = ({ + title, + subtitle, + percentage, + resetText, + isPrimary = false, + stale = false, + }: { + title: string; + subtitle: string; + percentage: number; + resetText?: string; + isPrimary?: boolean; + stale?: boolean; + }) => { + const status = getStatusInfo(percentage); + const StatusIcon = status.icon; + + return ( +
+
+
+

+ {title} +

+

{subtitle}

+
+
+ + + {Math.round(percentage)}% + +
+
+ + {resetText && ( +
+

+ + {resetText} +

+
+ )} +
+ ); + }; + + // Header Button + const maxPercentage = claudeUsage + ? Math.max(claudeUsage.sessionPercentage || 0, claudeUsage.weeklyPercentage || 0) + : 0; + + const getProgressBarColor = (percentage: number) => { + if (percentage >= 100) return "bg-red-500"; + if (percentage >= 75) return "bg-yellow-500"; + return "bg-green-500"; + }; + + const trigger = ( + + ); + + return ( + + {trigger} + + {/* Header */} +
+
+ Claude Usage +
+ +
+ + {/* Content */} +
+ {error ? ( +
+ +
+

{error}

+

+ Make sure Claude CLI is installed and authenticated via claude login +

+
+
+ ) : !claudeUsage ? ( + // Loading state +
+ +

Loading usage data...

+
+ ) : ( + <> + {/* Primary Card */} + + + {/* Secondary Cards Grid */} +
+ + +
+ + {/* Extra Usage / Cost */} + {claudeUsage.costLimit && claudeUsage.costLimit > 0 && ( + 0 + ? ((claudeUsage.costUsed ?? 0) / claudeUsage.costLimit) * 100 + : 0 + } + stale={isStale} + /> + )} + + )} +
+ + {/* Footer */} +
+ + Claude Status + + +
{/* Could add quick settings link here */}
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index b70c615d..044b6151 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -6,6 +6,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Plus, Bot } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; +import { ClaudeUsagePopover } from "@/components/claude-usage-popover"; interface BoardHeaderProps { projectName: string; @@ -37,6 +38,9 @@ export function BoardHeader({

{projectName}

+ {/* Usage Popover */} + {isMounted && } + {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && (
+
+ + +
); case "ai-enhancement": return ; diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx new file mode 100644 index 00000000..2ca061da --- /dev/null +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -0,0 +1,75 @@ +import { Clock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useState, useEffect } from "react"; +import { Slider } from "@/components/ui/slider"; +import { useAppStore } from "@/store/app-store"; + +export function ClaudeUsageSection() { + const { claudeRefreshInterval, setClaudeRefreshInterval } = useAppStore(); + const [localInterval, setLocalInterval] = useState(claudeRefreshInterval); + + // Sync local state with store when store changes (e.g. initial load) + useEffect(() => { + setLocalInterval(claudeRefreshInterval); + }, [claudeRefreshInterval]); + + return ( +
+
+
+
+
+
+

Claude Usage Tracking

+
+

+ Track your Claude Code usage limits. Uses the Claude CLI for data. +

+
+
+ {/* Info about CLI requirement */} +
+

Usage tracking requires Claude Code CLI to be installed and authenticated:

+
    +
  1. Install Claude Code CLI if not already installed
  2. +
  3. Run claude login to authenticate
  4. +
  5. Usage data will be fetched automatically
  6. +
+
+ + {/* Refresh Interval Section */} +
+
+

+ + Refresh Interval +

+

+ How often to check for usage updates. +

+
+ +
+ setLocalInterval(vals[0])} + onValueCommit={(vals) => setClaudeRefreshInterval(vals[0])} + min={30} + max={120} + step={5} + className="flex-1" + /> + {Math.max(30, Math.min(120, localInterval || 30))}s +
+
+
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index cdaaf67c..a085e8c6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -482,6 +482,9 @@ export interface ElectronAPI { sessionId: string ) => Promise<{ success: boolean; error?: string }>; }; + claude?: { + getUsage: () => Promise; + }; } // Note: Window interface is declared in @/types/electron.d.ts @@ -879,6 +882,33 @@ const getMockElectronAPI = (): ElectronAPI => { // Mock Running Agents API runningAgents: createMockRunningAgentsAPI(), + + // Mock Claude API + claude: { + getUsage: async () => { + console.log("[Mock] Getting Claude usage"); + return { + sessionTokensUsed: 0, + sessionLimit: 0, + sessionPercentage: 15, + sessionResetTime: new Date(Date.now() + 3600000).toISOString(), + sessionResetText: "Resets in 1h", + weeklyTokensUsed: 0, + weeklyLimit: 0, + weeklyPercentage: 5, + weeklyResetTime: new Date(Date.now() + 86400000 * 2).toISOString(), + weeklyResetText: "Resets Dec 23", + opusWeeklyTokensUsed: 0, + opusWeeklyPercentage: 1, + opusResetText: "Resets Dec 27", + costUsed: null, + costLimit: null, + costCurrency: null, + lastUpdated: new Date().toISOString(), + userTimezone: "UTC" + }; + }, + } }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 59c9305d..b78e8596 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1016,6 +1016,11 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; error?: string }> => this.httpDelete(`/api/sessions/${sessionId}`), }; + + // Claude API + claude = { + getUsage: (): Promise => this.get("/api/claude/usage"), + }; } // Singleton instance diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index f433578a..1601b1e8 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -507,6 +507,67 @@ export interface AppState { planContent: string; planningMode: "lite" | "spec" | "full"; } | null; + + // Claude Usage Tracking + claudeRefreshInterval: number; // Refresh interval in seconds (default: 60) + claudeUsage: ClaudeUsage | null; + claudeUsageLastUpdated: number | null; +} + +// Claude Usage interface matching the server response +export interface ClaudeUsage { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; + sessionResetText: string; + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; + weeklyResetText: string; + + opusWeeklyTokensUsed: number; + opusWeeklyPercentage: number; + opusResetText: string; + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; +} + +/** + * Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit) + * Returns true if any limit is reached, meaning auto mode should pause feature pickup. + */ +export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean { + if (!claudeUsage) { + // No usage data available - don't block + return false; + } + + // Check session limit (5-hour window) + if (claudeUsage.sessionPercentage >= 100) { + return true; + } + + // Check weekly limit + if (claudeUsage.weeklyPercentage >= 100) { + return true; + } + + // Check cost limit (if configured) + if ( + claudeUsage.costLimit !== null && + claudeUsage.costLimit > 0 && + claudeUsage.costUsed !== null && + claudeUsage.costUsed >= claudeUsage.costLimit + ) { + return true; + } + + return false; } // Default background settings for board backgrounds @@ -756,6 +817,11 @@ export interface AppActions { planningMode: "lite" | "spec" | "full"; } | null) => void; + // Claude Usage Tracking actions + setClaudeRefreshInterval: (interval: number) => void; + setClaudeUsageLastUpdated: (timestamp: number) => void; + setClaudeUsage: (usage: ClaudeUsage | null) => void; + // Reset reset: () => void; } @@ -848,6 +914,9 @@ const initialState: AppState = { defaultRequirePlanApproval: false, defaultAIProfileId: null, pendingPlanApproval: null, + claudeRefreshInterval: 60, + claudeUsage: null, + claudeUsageLastUpdated: null, }; export const useAppStore = create()( @@ -2280,6 +2349,14 @@ export const useAppStore = create()( // Plan Approval actions setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }), + // Claude Usage Tracking actions + setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }), + setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }), + setClaudeUsage: (usage: ClaudeUsage | null) => set({ + claudeUsage: usage, + claudeUsageLastUpdated: usage ? Date.now() : null, + }), + // Reset reset: () => set(initialState), }), @@ -2352,6 +2429,10 @@ export const useAppStore = create()( defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, defaultAIProfileId: state.defaultAIProfileId, + // Claude usage tracking + claudeUsage: state.claudeUsage, + claudeUsageLastUpdated: state.claudeUsageLastUpdated, + claudeRefreshInterval: state.claudeRefreshInterval, }), } ) From ebc7c9a7a08824462815fa9c39af25fa681cf18a Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:09:00 +0400 Subject: [PATCH 02/14] feat: hide usage tracking UI when API key is configured Usage tracking via CLI only works for Claude Code subscription users. Hide the Usage button and settings section when an Anthropic API key is set. --- .../src/components/views/board-view/board-header.tsx | 10 ++++++++-- apps/ui/src/components/views/settings-view.tsx | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 044b6151..a7c74b3c 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"; import { Plus, Bot } from "lucide-react"; import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts"; import { ClaudeUsagePopover } from "@/components/claude-usage-popover"; +import { useAppStore } from "@/store/app-store"; interface BoardHeaderProps { projectName: string; @@ -31,6 +32,11 @@ export function BoardHeader({ addFeatureShortcut, isMounted, }: BoardHeaderProps) { + const apiKeys = useAppStore((state) => state.apiKeys); + + // Hide usage tracking when using API key (only show for Claude Code CLI users) + const showUsageTracking = !apiKeys.anthropic; + return (
@@ -38,8 +44,8 @@ export function BoardHeader({

{projectName}

- {/* Usage Popover */} - {isMounted && } + {/* Usage Popover - only show for CLI users (not API key users) */} + {isMounted && showUsageTracking && } {/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && ( diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index b438672a..5100e7e6 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -47,8 +47,12 @@ export function SettingsView() { defaultAIProfileId, setDefaultAIProfileId, aiProfiles, + apiKeys, } = useAppStore(); + // Hide usage tracking when using API key (only show for Claude Code CLI users) + const showUsageTracking = !apiKeys.anthropic; + // Convert electron Project to settings-view Project type const convertProject = ( project: ElectronProject | null @@ -99,7 +103,7 @@ export function SettingsView() { isChecking={isCheckingClaudeCli} onRefresh={handleRefreshClaudeCli} /> - + {showUsageTracking && }
); case "ai-enhancement": From 0a2b4287ffed7f3441925c65012ab57fb959f4fb Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:11:16 +0400 Subject: [PATCH 03/14] Update apps/server/src/routes/claude/types.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/server/src/routes/claude/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/claude/types.ts b/apps/server/src/routes/claude/types.ts index d7baa0c5..2f6eb597 100644 --- a/apps/server/src/routes/claude/types.ts +++ b/apps/server/src/routes/claude/types.ts @@ -15,9 +15,9 @@ export type ClaudeUsage = { weeklyResetTime: string; // ISO date string weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" - opusWeeklyTokensUsed: number; - opusWeeklyPercentage: number; - opusResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" costUsed: number | null; costLimit: number | null; From 6150926a75dc79e8c4c4058ae42358546238dfae Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:11:24 +0400 Subject: [PATCH 04/14] Update apps/ui/src/lib/electron.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- apps/ui/src/lib/electron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index a085e8c6..6baa67f6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -483,7 +483,7 @@ export interface ElectronAPI { ) => Promise<{ success: boolean; error?: string }>; }; claude?: { - getUsage: () => Promise; + getUsage: () => Promise; }; } From 5e789c281721603f1518caa836b079cfdc0f7c4e Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:12:22 +0400 Subject: [PATCH 05/14] refactor: use node-pty instead of expect for cross-platform support Replace Unix-only 'expect' command with node-pty library which works on Windows, macOS, and Linux. Also fixes 'which' command to use 'where' on Windows for checking if Claude CLI is available. --- .../src/services/claude-usage-service.ts | 102 +++++++++--------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index a164a46d..8eaa630d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -1,4 +1,6 @@ import { spawn } from "child_process"; +import * as os from "os"; +import * as pty from "node-pty"; import { ClaudeUsage } from "../routes/claude/types.js"; /** @@ -8,7 +10,7 @@ import { ClaudeUsage } from "../routes/claude/types.js"; * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * - * Based on ClaudeBar's implementation approach. + * Uses node-pty for cross-platform PTY support (Windows, macOS, Linux). */ export class ClaudeUsageService { private claudeBinary = "claude"; @@ -19,7 +21,10 @@ export class ClaudeUsageService { */ async isAvailable(): Promise { return new Promise((resolve) => { - const proc = spawn("which", [this.claudeBinary]); + const isWindows = os.platform() === "win32"; + const checkCmd = isWindows ? "where" : "which"; + + const proc = spawn(checkCmd, [this.claudeBinary]); proc.on("close", (code) => { resolve(code === 0); }); @@ -39,90 +44,87 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses 'expect' to provide a pseudo-TTY since claude requires one + * Uses node-pty to provide a pseudo-TTY (cross-platform) */ private executeClaudeUsageCommand(): Promise { return new Promise((resolve, reject) => { - let stdout = ""; - let stderr = ""; + let output = ""; let settled = false; + let hasSeenUsageData = false; - // Use a simple working directory (home or tmp) - const workingDirectory = process.env.HOME || "/tmp"; + // Use home directory as working directory + const workingDirectory = os.homedir() || (os.platform() === "win32" ? process.env.USERPROFILE : "/tmp") || "/tmp"; - // 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 - const expectScript = ` - set timeout 20 - spawn claude /usage - expect { - "Current session" { - sleep 2 - send "\\x1b" - } - "Esc to cancel" { - sleep 3 - send "\\x1b" - } - timeout {} - eof {} - } - expect eof - `; + // Determine shell based on platform + const isWindows = os.platform() === "win32"; + const shell = isWindows ? "cmd.exe" : (process.env.SHELL || "/bin/bash"); + const shellArgs = isWindows ? ["/c", "claude", "/usage"] : ["-c", "claude /usage"]; - const proc = spawn("expect", ["-c", expectScript], { + const ptyProcess = pty.spawn(shell, shellArgs, { + name: "xterm-256color", + cols: 120, + rows: 30, cwd: workingDirectory, env: { ...process.env, TERM: "xterm-256color", - }, + } as Record, }); const timeoutId = setTimeout(() => { if (!settled) { settled = true; - proc.kill(); + ptyProcess.kill(); reject(new Error("Command timed out")); } }, this.timeout); - proc.stdout.on("data", (data) => { - stdout += data.toString(); + // Collect output + ptyProcess.onData((data) => { + output += data; + + // Check if we've seen the usage data (look for "Current session") + if (!hasSeenUsageData && output.includes("Current session")) { + hasSeenUsageData = true; + + // Wait a bit for full output, then send escape to exit + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 2000); + } + + // Fallback: if we see "Esc to cancel" but haven't seen usage data yet + if (!hasSeenUsageData && output.includes("Esc to cancel")) { + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 3000); + } }); - proc.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { + ptyProcess.onExit(({ exitCode }) => { clearTimeout(timeoutId); if (settled) return; settled = true; // Check for authentication errors in output - if (stdout.includes("token_expired") || stdout.includes("authentication_error") || - stderr.includes("token_expired") || stderr.includes("authentication_error")) { + if (output.includes("token_expired") || output.includes("authentication_error")) { reject(new Error("Authentication required - please run 'claude login'")); return; } // Even if exit code is non-zero, we might have useful output - if (stdout.trim()) { - resolve(stdout); - } else if (code !== 0) { - reject(new Error(stderr || `Command exited with code ${code}`)); + if (output.trim()) { + resolve(output); + } else if (exitCode !== 0) { + reject(new Error(`Command exited with code ${exitCode}`)); } else { reject(new Error("No output from claude command")); } }); - - proc.on("error", (err) => { - clearTimeout(timeoutId); - if (!settled) { - settled = true; - reject(new Error(`Failed to execute claude: ${err.message}`)); - } - }); }); } From 86cbb2f9708258c51809d4b1e46c306c3dadc948 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:17:51 +0400 Subject: [PATCH 06/14] Revert "refactor: use node-pty instead of expect for cross-platform support" This reverts commit 5e789c281721603f1518caa836b079cfdc0f7c4e. --- .../src/services/claude-usage-service.ts | 102 +++++++++--------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 8eaa630d..a164a46d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -1,6 +1,4 @@ import { spawn } from "child_process"; -import * as os from "os"; -import * as pty from "node-pty"; import { ClaudeUsage } from "../routes/claude/types.js"; /** @@ -10,7 +8,7 @@ import { ClaudeUsage } from "../routes/claude/types.js"; * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * - * Uses node-pty for cross-platform PTY support (Windows, macOS, Linux). + * Based on ClaudeBar's implementation approach. */ export class ClaudeUsageService { private claudeBinary = "claude"; @@ -21,10 +19,7 @@ export class ClaudeUsageService { */ async isAvailable(): Promise { return new Promise((resolve) => { - const isWindows = os.platform() === "win32"; - const checkCmd = isWindows ? "where" : "which"; - - const proc = spawn(checkCmd, [this.claudeBinary]); + const proc = spawn("which", [this.claudeBinary]); proc.on("close", (code) => { resolve(code === 0); }); @@ -44,87 +39,90 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses node-pty to provide a pseudo-TTY (cross-platform) + * Uses 'expect' to provide a pseudo-TTY since claude requires one */ private executeClaudeUsageCommand(): Promise { return new Promise((resolve, reject) => { - let output = ""; + let stdout = ""; + let stderr = ""; let settled = false; - let hasSeenUsageData = false; - // Use home directory as working directory - const workingDirectory = os.homedir() || (os.platform() === "win32" ? process.env.USERPROFILE : "/tmp") || "/tmp"; + // Use a simple working directory (home or tmp) + const workingDirectory = process.env.HOME || "/tmp"; - // Determine shell based on platform - const isWindows = os.platform() === "win32"; - const shell = isWindows ? "cmd.exe" : (process.env.SHELL || "/bin/bash"); - const shellArgs = isWindows ? ["/c", "claude", "/usage"] : ["-c", "claude /usage"]; + // 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 + const expectScript = ` + set timeout 20 + spawn claude /usage + expect { + "Current session" { + sleep 2 + send "\\x1b" + } + "Esc to cancel" { + sleep 3 + send "\\x1b" + } + timeout {} + eof {} + } + expect eof + `; - const ptyProcess = pty.spawn(shell, shellArgs, { - name: "xterm-256color", - cols: 120, - rows: 30, + const proc = spawn("expect", ["-c", expectScript], { cwd: workingDirectory, env: { ...process.env, TERM: "xterm-256color", - } as Record, + }, }); const timeoutId = setTimeout(() => { if (!settled) { settled = true; - ptyProcess.kill(); + proc.kill(); reject(new Error("Command timed out")); } }, this.timeout); - // Collect output - ptyProcess.onData((data) => { - output += data; - - // Check if we've seen the usage data (look for "Current session") - if (!hasSeenUsageData && output.includes("Current session")) { - hasSeenUsageData = true; - - // Wait a bit for full output, then send escape to exit - setTimeout(() => { - if (!settled) { - ptyProcess.write("\x1b"); // Send escape key - } - }, 2000); - } - - // Fallback: if we see "Esc to cancel" but haven't seen usage data yet - if (!hasSeenUsageData && output.includes("Esc to cancel")) { - setTimeout(() => { - if (!settled) { - ptyProcess.write("\x1b"); // Send escape key - } - }, 3000); - } + proc.stdout.on("data", (data) => { + stdout += data.toString(); }); - ptyProcess.onExit(({ exitCode }) => { + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { clearTimeout(timeoutId); if (settled) return; settled = true; // Check for authentication errors in output - if (output.includes("token_expired") || output.includes("authentication_error")) { + if (stdout.includes("token_expired") || stdout.includes("authentication_error") || + stderr.includes("token_expired") || stderr.includes("authentication_error")) { reject(new Error("Authentication required - please run 'claude login'")); return; } // Even if exit code is non-zero, we might have useful output - if (output.trim()) { - resolve(output); - } else if (exitCode !== 0) { - reject(new Error(`Command exited with code ${exitCode}`)); + if (stdout.trim()) { + resolve(stdout); + } else if (code !== 0) { + reject(new Error(stderr || `Command exited with code ${code}`)); } else { reject(new Error("No output from claude command")); } }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + if (!settled) { + settled = true; + reject(new Error(`Failed to execute claude: ${err.message}`)); + } + }); }); } From 7416c8b428bd05c154854d82d28d8fdd27718df6 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:23:56 +0400 Subject: [PATCH 07/14] style: removed tiny clock --- apps/ui/src/components/claude-usage-popover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 23182744..fcff9320 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -158,7 +158,7 @@ export function ClaudeUsagePopover() { {resetText && (

- + {title === "Session Usage" && } {resetText}

From 6533a15653cabb6528452e3c077e8537a833bb8f Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:26:18 +0400 Subject: [PATCH 08/14] feat: add Windows support using node-pty while keeping expect for macOS Platform-specific implementations: - macOS: Uses 'expect' command (unchanged, working) - Windows: Uses node-pty for PTY support Also fixes 'which' vs 'where' for checking Claude CLI availability. --- .../src/services/claude-usage-service.ts | 114 ++++++++++++++++-- 1 file changed, 103 insertions(+), 11 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index a164a46d..7b745bae 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -1,4 +1,6 @@ import { spawn } from "child_process"; +import * as os from "os"; +import * as pty from "node-pty"; import { ClaudeUsage } from "../routes/claude/types.js"; /** @@ -8,18 +10,22 @@ import { ClaudeUsage } from "../routes/claude/types.js"; * This approach doesn't require any API keys - it relies on the user * having already authenticated via `claude login`. * - * Based on ClaudeBar's implementation approach. + * Platform-specific implementations: + * - macOS: Uses 'expect' command for PTY + * - Windows: Uses node-pty for PTY */ export class ClaudeUsageService { private claudeBinary = "claude"; private timeout = 30000; // 30 second timeout + private isWindows = os.platform() === "win32"; /** * Check if Claude CLI is available on the system */ async isAvailable(): Promise { return new Promise((resolve) => { - const proc = spawn("which", [this.claudeBinary]); + const checkCmd = this.isWindows ? "where" : "which"; + const proc = spawn(checkCmd, [this.claudeBinary]); proc.on("close", (code) => { resolve(code === 0); }); @@ -39,9 +45,19 @@ export class ClaudeUsageService { /** * Execute the claude /usage command and return the output - * Uses 'expect' to provide a pseudo-TTY since claude requires one + * Uses platform-specific PTY implementation */ private executeClaudeUsageCommand(): Promise { + if (this.isWindows) { + return this.executeClaudeUsageCommandWindows(); + } + return this.executeClaudeUsageCommandMac(); + } + + /** + * macOS implementation using 'expect' command + */ + private executeClaudeUsageCommandMac(): Promise { return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; @@ -126,6 +142,82 @@ export class ClaudeUsageService { }); } + /** + * Windows implementation using node-pty + */ + private executeClaudeUsageCommandWindows(): Promise { + return new Promise((resolve, reject) => { + let output = ""; + let settled = false; + let hasSeenUsageData = false; + + const workingDirectory = process.env.USERPROFILE || os.homedir() || "C:\\"; + + const ptyProcess = pty.spawn("cmd.exe", ["/c", "claude", "/usage"], { + name: "xterm-256color", + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: "xterm-256color", + } as Record, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + ptyProcess.kill(); + reject(new Error("Command timed out")); + } + }, this.timeout); + + ptyProcess.onData((data) => { + output += data; + + // Check if we've seen the usage data (look for "Current session") + if (!hasSeenUsageData && output.includes("Current session")) { + hasSeenUsageData = true; + // Wait for full output, then send escape to exit + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 2000); + } + + // Fallback: if we see "Esc to cancel" but haven't seen usage data yet + if (!hasSeenUsageData && output.includes("Esc to cancel")) { + setTimeout(() => { + if (!settled) { + ptyProcess.write("\x1b"); // Send escape key + } + }, 3000); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if (output.includes("token_expired") || output.includes("authentication_error")) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + if (output.trim()) { + resolve(output); + } else if (exitCode !== 0) { + reject(new Error(`Command exited with code ${exitCode}`)); + } else { + reject(new Error("No output from claude command")); + } + }); + }); + } + /** * Strip ANSI escape codes from text */ @@ -165,12 +257,12 @@ export class ClaudeUsageService { const weeklyData = this.parseSection(lines, "Current week (all models)", "weekly"); // Parse Sonnet/Opus usage - try different labels - let opusData = this.parseSection(lines, "Current week (Sonnet only)", "opus"); - if (opusData.percentage === 0) { - opusData = this.parseSection(lines, "Current week (Sonnet)", "opus"); + let sonnetData = this.parseSection(lines, "Current week (Sonnet only)", "sonnet"); + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, "Current week (Sonnet)", "sonnet"); } - if (opusData.percentage === 0) { - opusData = this.parseSection(lines, "Current week (Opus)", "opus"); + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, "Current week (Opus)", "sonnet"); } return { @@ -186,9 +278,9 @@ export class ClaudeUsageService { weeklyResetTime: weeklyData.resetTime, weeklyResetText: weeklyData.resetText, - opusWeeklyTokensUsed: 0, // Not available from CLI - opusWeeklyPercentage: opusData.percentage, - opusResetText: opusData.resetText, + sonnetWeeklyTokensUsed: 0, // Not available from CLI + sonnetWeeklyPercentage: sonnetData.percentage, + sonnetResetText: sonnetData.resetText, costUsed: null, // Not available from CLI costLimit: null, From f2582c44539ecccbcaaf54739659bf38d4200413 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:32:30 +0400 Subject: [PATCH 09/14] fix: handle NaN percentage values and rename opus to sonnet - Show 'N/A' and dim card when percentage is NaN/invalid - Use gray progress bar for invalid values - Rename opusWeekly* properties to sonnetWeekly* to match server types --- .../src/components/claude-usage-popover.tsx | 42 +++++++++++-------- apps/ui/src/lib/electron.ts | 6 +-- apps/ui/src/store/app-store.ts | 6 +-- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index fcff9320..84131e8d 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -123,7 +123,11 @@ export function ClaudeUsagePopover() { isPrimary?: boolean; stale?: boolean; }) => { - const status = getStatusInfo(percentage); + // Check if percentage is valid (not NaN, not undefined, is a finite number) + const isValidPercentage = typeof percentage === "number" && !isNaN(percentage) && isFinite(percentage); + const safePercentage = isValidPercentage ? percentage : 0; + + const status = getStatusInfo(safePercentage); const StatusIcon = status.icon; return ( @@ -131,7 +135,7 @@ export function ClaudeUsagePopover() { className={cn( "rounded-xl border bg-card/50 p-4 transition-opacity", isPrimary ? "border-border/60 shadow-sm" : "border-border/40", - stale && "opacity-60" + (stale || !isValidPercentage) && "opacity-50" )} >
@@ -141,20 +145,24 @@ export function ClaudeUsagePopover() {

{subtitle}

-
- - - {Math.round(percentage)}% - -
+ {isValidPercentage ? ( +
+ + + {Math.round(safePercentage)}% + +
+ ) : ( + N/A + )}
- + {resetText && (

@@ -267,8 +275,8 @@ export function ClaudeUsagePopover() {

diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 6baa67f6..5e01b492 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -898,9 +898,9 @@ const getMockElectronAPI = (): ElectronAPI => { weeklyPercentage: 5, weeklyResetTime: new Date(Date.now() + 86400000 * 2).toISOString(), weeklyResetText: "Resets Dec 23", - opusWeeklyTokensUsed: 0, - opusWeeklyPercentage: 1, - opusResetText: "Resets Dec 27", + sonnetWeeklyTokensUsed: 0, + sonnetWeeklyPercentage: 1, + sonnetResetText: "Resets Dec 27", costUsed: null, costLimit: null, costCurrency: null, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 1601b1e8..365962c9 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -528,9 +528,9 @@ export interface ClaudeUsage { weeklyResetTime: string; weeklyResetText: string; - opusWeeklyTokensUsed: number; - opusWeeklyPercentage: number; - opusResetText: string; + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; costUsed: number | null; costLimit: number | null; From ab0487664aff21177e456da7b26f694fb48369e2 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 08:46:11 +0400 Subject: [PATCH 10/14] feat: integrate ClaudeUsageService and update API routes for usage tracking --- apps/server/src/index.ts | 4 +++- apps/server/src/routes/claude/index.ts | 3 +-- .../src/components/claude-usage-popover.tsx | 20 +++++++------------ apps/ui/src/lib/electron.ts | 3 ++- apps/ui/src/lib/http-api-client.ts | 4 ++-- apps/ui/src/store/app-store.ts | 12 +++++++++-- 6 files changed, 25 insertions(+), 21 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 51e92e80..852c5ddf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -45,6 +45,7 @@ import { getTerminalService } from "./services/terminal-service.js"; import { SettingsService } from "./services/settings-service.js"; import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js"; import { createClaudeRoutes } from "./routes/claude/index.js"; +import { ClaudeUsageService } from "./services/claude-usage-service.js"; // Load environment variables dotenv.config(); @@ -112,6 +113,7 @@ const agentService = new AgentService(DATA_DIR, events); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events); const settingsService = new SettingsService(DATA_DIR); +const claudeUsageService = new ClaudeUsageService(); // Initialize services (async () => { @@ -142,7 +144,7 @@ app.use("/api/workspace", createWorkspaceRoutes()); app.use("/api/templates", createTemplatesRoutes()); app.use("/api/terminal", createTerminalRoutes()); app.use("/api/settings", createSettingsRoutes(settingsService)); -app.use("/api/claude", createClaudeRoutes()); +app.use("/api/claude", createClaudeRoutes(claudeUsageService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/claude/index.ts b/apps/server/src/routes/claude/index.ts index 8f359d5f..f951aa34 100644 --- a/apps/server/src/routes/claude/index.ts +++ b/apps/server/src/routes/claude/index.ts @@ -1,9 +1,8 @@ import { Router, Request, Response } from "express"; import { ClaudeUsageService } from "../../services/claude-usage-service.js"; -export function createClaudeRoutes(): Router { +export function createClaudeRoutes(service: ClaudeUsageService): Router { const router = Router(); - const service = new ClaudeUsageService(); // Get current usage (fetches from Claude CLI) router.get("/usage", async (req: Request, res: Response) => { diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 84131e8d..3288e5f1 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { Popover, PopoverContent, @@ -29,7 +29,7 @@ export function ClaudeUsagePopover() { return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; }, [claudeUsageLastUpdated]); - const fetchUsage = async (isAutoRefresh = false) => { + const fetchUsage = useCallback(async (isAutoRefresh = false) => { if (!isAutoRefresh) setLoading(true); setError(null); try { @@ -38,7 +38,7 @@ export function ClaudeUsagePopover() { throw new Error("Claude API not available"); } const data = await api.claude.getUsage(); - if (data.error) { + if ("error" in data) { throw new Error(data.message || data.error); } setClaudeUsage(data); @@ -47,26 +47,20 @@ export function ClaudeUsagePopover() { } finally { if (!isAutoRefresh) setLoading(false); } - }; + }, [setClaudeUsage]); // Auto-fetch on mount if data is stale useEffect(() => { if (isStale) { fetchUsage(true); } - }, []); + }, [isStale, fetchUsage]); useEffect(() => { // Initial fetch when opened if (open) { - if (!claudeUsage) { + if (!claudeUsage || isStale) { fetchUsage(); - } else { - const now = Date.now(); - const stale = !claudeUsageLastUpdated || now - claudeUsageLastUpdated > 2 * 60 * 1000; - if (stale) { - fetchUsage(false); - } } } @@ -81,7 +75,7 @@ export function ClaudeUsagePopover() { return () => { if (intervalId) clearInterval(intervalId); }; - }, [open]); + }, [open, claudeUsage, isStale, claudeRefreshInterval, fetchUsage]); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 5e01b492..bdb09748 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,5 +1,6 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from "@/types/electron"; +import type { ClaudeUsageResponse } from "@/store/app-store"; import { getJSON, setJSON, removeItem } from "./storage"; export interface FileEntry { @@ -483,7 +484,7 @@ export interface ElectronAPI { ) => Promise<{ success: boolean; error?: string }>; }; claude?: { - getUsage: () => Promise; + getUsage: () => Promise; }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b78e8596..b713472a 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -24,7 +24,7 @@ import type { SuggestionType, } from "./electron"; import type { Message, SessionListItem } from "@/types/electron"; -import type { Feature } from "@/store/app-store"; +import type { Feature, ClaudeUsageResponse } from "@/store/app-store"; import type { WorktreeAPI, GitAPI, @@ -1019,7 +1019,7 @@ export class HttpApiClient implements ElectronAPI { // Claude API claude = { - getUsage: (): Promise => this.get("/api/claude/usage"), + getUsage: (): Promise => this.get("/api/claude/usage"), }; } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 365962c9..4afafc2c 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -515,7 +515,7 @@ export interface AppState { } // Claude Usage interface matching the server response -export interface ClaudeUsage { +export type ClaudeUsage = { sessionTokensUsed: number; sessionLimit: number; sessionPercentage: number; @@ -535,7 +535,15 @@ export interface ClaudeUsage { costUsed: number | null; costLimit: number | null; costCurrency: string | null; -} + + lastUpdated: string; + userTimezone: string; +}; + +// Response type for Claude usage API (can be success or error) +export type ClaudeUsageResponse = + | ClaudeUsage + | { error: string; message?: string }; /** * Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit) From 5be85a45b1bc9af2606a01a60b375c199bb9c7b7 Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 10:30:06 +0400 Subject: [PATCH 11/14] fix: Update error handling in ClaudeUsagePopover and improve type safety in app-store --- .../src/components/claude-usage-popover.tsx | 92 +++++++++++++------ apps/ui/src/store/app-store.ts | 2 +- 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/apps/ui/src/components/claude-usage-popover.tsx b/apps/ui/src/components/claude-usage-popover.tsx index 3288e5f1..60b22f29 100644 --- a/apps/ui/src/components/claude-usage-popover.tsx +++ b/apps/ui/src/components/claude-usage-popover.tsx @@ -17,37 +17,65 @@ import { cn } from "@/lib/utils"; import { getElectronAPI } from "@/lib/electron"; import { useAppStore } from "@/store/app-store"; +// Error codes for distinguishing failure modes +const ERROR_CODES = { + API_BRIDGE_UNAVAILABLE: "API_BRIDGE_UNAVAILABLE", + AUTH_ERROR: "AUTH_ERROR", + UNKNOWN: "UNKNOWN", +} as const; + +type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +type UsageError = { + code: ErrorCode; + message: string; +}; + export function ClaudeUsagePopover() { const { claudeRefreshInterval, claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); // Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes const isStale = useMemo(() => { return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; }, [claudeUsageLastUpdated]); - const fetchUsage = useCallback(async (isAutoRefresh = false) => { - if (!isAutoRefresh) setLoading(true); - setError(null); - try { - const api = getElectronAPI(); - if (!api.claude) { - throw new Error("Claude API not available"); + const fetchUsage = useCallback( + async (isAutoRefresh = false) => { + if (!isAutoRefresh) setLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.claude) { + setError({ + code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, + message: 'Claude API bridge not available', + }); + return; + } + const data = await api.claude.getUsage(); + if ('error' in data) { + setError({ + code: ERROR_CODES.AUTH_ERROR, + message: data.message || data.error, + }); + return; + } + setClaudeUsage(data); + } catch (err) { + setError({ + code: ERROR_CODES.UNKNOWN, + message: err instanceof Error ? err.message : 'Failed to fetch usage', + }); + } finally { + if (!isAutoRefresh) setLoading(false); } - const data = await api.claude.getUsage(); - if ("error" in data) { - throw new Error(data.message || data.error); - } - setClaudeUsage(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch usage"); - } finally { - if (!isAutoRefresh) setLoading(false); - } - }, [setClaudeUsage]); + }, + [setClaudeUsage] + ); // Auto-fetch on mount if data is stale useEffect(() => { @@ -79,11 +107,10 @@ export function ClaudeUsagePopover() { // Derived status color/icon helper const getStatusInfo = (percentage: number) => { - if (percentage >= 80) - return { color: "text-red-500", icon: XCircle, bg: "bg-red-500" }; + if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' }; if (percentage >= 50) - return { color: "text-orange-500", icon: AlertTriangle, bg: "bg-orange-500" }; - return { color: "text-green-500", icon: CheckCircle, bg: "bg-green-500" }; + return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' }; + return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' }; }; // Helper component for the progress bar @@ -175,8 +202,8 @@ export function ClaudeUsagePopover() { : 0; const getProgressBarColor = (percentage: number) => { - if (percentage >= 100) return "bg-red-500"; - if (percentage >= 75) return "bg-yellow-500"; + if (percentage >= 80) return 'bg-red-500'; + if (percentage >= 50) return 'bg-yellow-500'; return "bg-green-500"; }; @@ -220,7 +247,7 @@ export function ClaudeUsagePopover() { + {error && ( + + )}
{/* Content */} @@ -337,7 +341,7 @@ export function ClaudeUsagePopover() { Claude Status -
{/* Could add quick settings link here */}
+ Updates every minute
diff --git a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx index 2ca061da..cfde650d 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/claude-usage-section.tsx @@ -1,18 +1,6 @@ -import { Clock } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useState, useEffect } from "react"; -import { Slider } from "@/components/ui/slider"; -import { useAppStore } from "@/store/app-store"; export function ClaudeUsageSection() { - const { claudeRefreshInterval, setClaudeRefreshInterval } = useAppStore(); - const [localInterval, setLocalInterval] = useState(claudeRefreshInterval); - - // Sync local state with store when store changes (e.g. initial load) - useEffect(() => { - setLocalInterval(claudeRefreshInterval); - }, [claudeRefreshInterval]); - return (
  • Install Claude Code CLI if not already installed
  • Run claude login to authenticate
  • -
  • Usage data will be fetched automatically
  • +
  • Usage data will be fetched automatically every ~minute
  • - - {/* Refresh Interval Section */} -
    -
    -

    - - Refresh Interval -

    -

    - How often to check for usage updates. -

    -
    - -
    - setLocalInterval(vals[0])} - onValueCommit={(vals) => setClaudeRefreshInterval(vals[0])} - min={30} - max={120} - step={5} - className="flex-1" - /> - {Math.max(30, Math.min(120, localInterval || 30))}s -
    -
    ); From b80773b90db8a73e32416a9900369d573e7fa5af Mon Sep 17 00:00:00 2001 From: Mohamad Yahia Date: Sun, 21 Dec 2025 11:11:33 +0400 Subject: [PATCH 13/14] fix: Enhance usage tracking visibility logic in BoardHeader and SettingsView components --- apps/ui/src/components/views/board-view/board-header.tsx | 4 +++- apps/ui/src/components/views/settings-view.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index a7c74b3c..f7be59cf 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -35,7 +35,9 @@ export function BoardHeader({ const apiKeys = useAppStore((state) => state.apiKeys); // Hide usage tracking when using API key (only show for Claude Code CLI users) - const showUsageTracking = !apiKeys.anthropic; + // Also hide on Windows for now (CLI usage command not supported) + const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); + const showUsageTracking = !apiKeys.anthropic && !isWindows; return (
    diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 5100e7e6..ad132409 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -51,7 +51,9 @@ export function SettingsView() { } = useAppStore(); // Hide usage tracking when using API key (only show for Claude Code CLI users) - const showUsageTracking = !apiKeys.anthropic; + // Also hide on Windows for now (CLI usage command not supported) + const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); + const showUsageTracking = !apiKeys.anthropic && !isWindows; // Convert electron Project to settings-view Project type const convertProject = ( From ee9ccd03d636641f48607348077a749a037dc150 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Dec 2025 15:37:50 -0500 Subject: [PATCH 14/14] chore: remove Claude Code Review workflow file This commit deletes the .github/workflows/claude-code-review.yml file, which contained the configuration for the Claude Code Review GitHub Action. The removal is part of a cleanup process to streamline workflows and eliminate unused configurations. --- .github/workflows/claude-code-review.yml | 57 ------------------------ 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 8452b0f2..00000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' -