From 1c90d5325e11a3608ecfd9d592fa932617523fb7 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 13:59:49 -0500 Subject: [PATCH 01/17] Add 'kernel claude' command group for Claude extension support Implement a complete workflow for using the Claude for Chrome extension in Kernel browsers: Commands: - kernel claude extract: Extract extension + auth from local Chrome - kernel claude launch: Create browser with Claude pre-loaded - kernel claude load: Load extension into existing browser - kernel claude status: Check extension/auth status - kernel claude send: Send single message (scriptable) - kernel claude chat: Interactive TUI chat session Features: - Cross-platform Chrome path detection (macOS, Linux, Windows) - Bundle format with extension and auth storage - Embedded Playwright scripts for browser automation - Scriptable send command with stdin/file/JSON support - Interactive chat with /help, /status, /clear commands --- README.md | 83 ++++++++ cmd/claude/chat.go | 259 ++++++++++++++++++++++++ cmd/claude/claude.go | 51 +++++ cmd/claude/extract.go | 151 ++++++++++++++ cmd/claude/launch.go | 192 ++++++++++++++++++ cmd/claude/load.go | 104 ++++++++++ cmd/claude/send.go | 185 +++++++++++++++++ cmd/claude/status.go | 154 ++++++++++++++ cmd/root.go | 2 + internal/claude/bundle.go | 249 +++++++++++++++++++++++ internal/claude/bundle_test.go | 181 +++++++++++++++++ internal/claude/constants.go | 35 ++++ internal/claude/loader.go | 113 +++++++++++ internal/claude/paths.go | 157 ++++++++++++++ internal/claude/paths_test.go | 127 ++++++++++++ internal/claude/scripts.go | 17 ++ internal/claude/scripts/check_status.js | 68 +++++++ internal/claude/scripts/send_message.js | 99 +++++++++ internal/claude/scripts/stream_chat.js | 123 +++++++++++ 19 files changed, 2350 insertions(+) create mode 100644 cmd/claude/chat.go create mode 100644 cmd/claude/claude.go create mode 100644 cmd/claude/extract.go create mode 100644 cmd/claude/launch.go create mode 100644 cmd/claude/load.go create mode 100644 cmd/claude/send.go create mode 100644 cmd/claude/status.go create mode 100644 internal/claude/bundle.go create mode 100644 internal/claude/bundle_test.go create mode 100644 internal/claude/constants.go create mode 100644 internal/claude/loader.go create mode 100644 internal/claude/paths.go create mode 100644 internal/claude/paths_test.go create mode 100644 internal/claude/scripts.go create mode 100644 internal/claude/scripts/check_status.js create mode 100644 internal/claude/scripts/send_message.js create mode 100644 internal/claude/scripts/stream_chat.js diff --git a/README.md b/README.md index 4d73dc3..a1132b1 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,40 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--timeout ` - Maximum execution time in seconds (defaults server-side) - If `[code]` is omitted, code is read from stdin +### Claude Extension + +The `kernel claude` commands provide a complete workflow for using the Claude for Chrome extension in Kernel browsers: + +- `kernel claude extract` - Extract Claude extension from local Chrome + - `-o, --output ` - Output path for the bundle zip file (default: claude-bundle.zip) + - `--chrome-profile ` - Chrome profile name to extract from (default: Default) + - `--no-auth` - Skip authentication storage (extension will require login) + - `--list-profiles` - List available Chrome profiles and exit + +- `kernel claude launch` - Create a browser with Claude extension loaded + - `-b, --bundle ` - Path to the Claude bundle zip file (required) + - `-t, --timeout ` - Session timeout in seconds (default: 600) + - `-s, --stealth` - Launch browser in stealth mode + - `-H, --headless` - Launch browser in headless mode + - `--url ` - Initial URL to navigate to (default: https://claude.ai) + - `--chat` - Start interactive chat after launch + - `--viewport ` - Browser viewport size (e.g., 1920x1080@25) + +- `kernel claude load ` - Load Claude extension into existing browser + - `-b, --bundle ` - Path to the Claude bundle zip file (required) + +- `kernel claude status ` - Check Claude extension status + - `-o, --output json` - Output format: json for raw response + +- `kernel claude send [message]` - Send a message to Claude + - `-f, --file ` - Read message from file + - `--timeout ` - Response timeout in seconds (default: 120) + - `--json` - Output response as JSON + - `--raw` - Output raw response without formatting + +- `kernel claude chat ` - Interactive chat with Claude + - `--no-tui` - Disable interactive mode (line-by-line I/O) + ### Extension Management - `kernel extensions list` - List all uploaded extensions @@ -528,6 +562,55 @@ return { opsPerSec, ops, durationMs }; TS ``` +### Claude Extension + +```bash +# Step 1: Extract the Claude extension from your local Chrome (run on your machine) +kernel claude extract -o claude-bundle.zip + +# List available Chrome profiles +kernel claude extract --list-profiles + +# Extract from a specific Chrome profile +kernel claude extract --chrome-profile "Profile 1" -o claude-bundle.zip + +# Extract without authentication (will require login) +kernel claude extract --no-auth -o claude-bundle.zip + +# Step 2: Launch a browser with Claude pre-loaded +kernel claude launch -b claude-bundle.zip + +# Launch with longer timeout (1 hour) +kernel claude launch -b claude-bundle.zip -t 3600 + +# Launch in stealth mode +kernel claude launch -b claude-bundle.zip --stealth + +# Launch and immediately start interactive chat +kernel claude launch -b claude-bundle.zip --chat + +# Load Claude into an existing browser +kernel claude load abc123xyz -b claude-bundle.zip + +# Check Claude extension status +kernel claude status abc123xyz + +# Send a single message (great for scripting) +kernel claude send abc123xyz "What is 2+2?" + +# Pipe a message from stdin +echo "Explain this code" | kernel claude send abc123xyz + +# Read message from a file +kernel claude send abc123xyz -f prompt.txt + +# Get response as JSON +kernel claude send abc123xyz "Hello" --json + +# Start interactive chat session +kernel claude chat abc123xyz +``` + ### Extension management ```bash diff --git a/cmd/claude/chat.go b/cmd/claude/chat.go new file mode 100644 index 0000000..c3d9f84 --- /dev/null +++ b/cmd/claude/chat.go @@ -0,0 +1,259 @@ +package claude + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/onkernel/cli/internal/claude" + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var chatCmd = &cobra.Command{ + Use: "chat ", + Short: "Interactive chat with Claude", + Long: `Start an interactive chat session with Claude in a Kernel browser. + +This provides a simple command-line interface for having a conversation +with Claude. Type your messages and receive responses directly in the terminal. + +Special commands: + /quit, /exit - Exit the chat session + /clear - Clear the terminal + /status - Check extension status + /help - Show available commands`, + Example: ` # Start chat with existing browser + kernel claude chat abc123xyz + + # Launch new browser and start chatting + kernel claude launch -b claude-bundle.zip --chat`, + Args: cobra.ExactArgs(1), + RunE: runChat, +} + +func init() { + chatCmd.Flags().Bool("no-tui", false, "Disable interactive mode (line-by-line I/O)") +} + +func runChat(cmd *cobra.Command, args []string) error { + browserID := args[0] + // noTUI, _ := cmd.Flags().GetBool("no-tui") + // For now, both modes use the same implementation + + ctx := cmd.Context() + client := util.GetKernelClient(cmd) + + return runChatWithBrowser(ctx, client, browserID) +} + +func runChatWithBrowser(ctx context.Context, client kernel.Client, browserID string) error { + // Verify the browser exists + pterm.Info.Printf("Connecting to browser: %s\n", browserID) + + browser, err := client.Browsers.Get(ctx, browserID) + if err != nil { + return fmt.Errorf("failed to get browser: %w", err) + } + + // Check Claude status first + pterm.Info.Println("Checking Claude extension status...") + statusResult, err := client.Browsers.Playwright.Execute(ctx, browser.SessionID, kernel.BrowserPlaywrightExecuteParams{ + Code: claude.CheckStatusScript, + TimeoutSec: kernel.Opt(int64(30)), + }) + if err != nil { + return fmt.Errorf("failed to check status: %w", err) + } + + if statusResult.Result != nil { + var status struct { + ExtensionLoaded bool `json:"extensionLoaded"` + Authenticated bool `json:"authenticated"` + Error string `json:"error"` + } + resultBytes, _ := json.Marshal(statusResult.Result) + _ = json.Unmarshal(resultBytes, &status) + + if !status.ExtensionLoaded { + return fmt.Errorf("Claude extension is not loaded. Load it first with: kernel claude load %s -b claude-bundle.zip", browserID) + } + if !status.Authenticated { + pterm.Warning.Println("Claude extension is not authenticated.") + pterm.Info.Printf("Please log in via the live view: %s\n", browser.BrowserLiveViewURL) + return fmt.Errorf("authentication required") + } + } + + // Display chat header + pterm.Println() + pterm.DefaultHeader.WithBackgroundStyle(pterm.NewStyle(pterm.BgBlue)). + WithTextStyle(pterm.NewStyle(pterm.FgWhite)). + Println("Claude Chat") + pterm.Println() + pterm.Info.Printf("Browser: %s\n", browserID) + pterm.Info.Printf("Live View: %s\n", browser.BrowserLiveViewURL) + pterm.Println() + pterm.Info.Println("Type your message and press Enter. Use /help for commands, /quit to exit.") + pterm.Println() + + // Start the chat loop + scanner := bufio.NewScanner(os.Stdin) + messageCount := 0 + + for { + // Show prompt + pterm.Print(pterm.Cyan("You: ")) + + if !scanner.Scan() { + break + } + + input := strings.TrimSpace(scanner.Text()) + if input == "" { + continue + } + + // Handle special commands + if strings.HasPrefix(input, "/") { + handled, shouldExit := handleChatCommand(ctx, client, browserID, input) + if shouldExit { + pterm.Info.Println("Goodbye!") + return nil + } + if handled { + continue + } + } + + // Send the message + messageCount++ + spinner, _ := pterm.DefaultSpinner.Start("Claude is thinking...") + + response, err := sendChatMessage(ctx, client, browser.SessionID, input) + spinner.Stop() + + if err != nil { + pterm.Error.Printf("Error: %v\n", err) + pterm.Println() + continue + } + + // Display the response + pterm.Println() + pterm.Print(pterm.Green("Claude: ")) + fmt.Println(response) + pterm.Println() + } + + return nil +} + +func handleChatCommand(ctx context.Context, client kernel.Client, browserID, input string) (handled bool, shouldExit bool) { + parts := strings.Fields(input) + if len(parts) == 0 { + return false, false + } + + cmd := strings.ToLower(parts[0]) + + switch cmd { + case "/quit", "/exit", "/q": + return true, true + + case "/clear": + // Clear terminal (works on most terminals) + fmt.Print("\033[H\033[2J") + pterm.Info.Println("Terminal cleared.") + return true, false + + case "/status": + pterm.Info.Println("Checking status...") + result, err := client.Browsers.Playwright.Execute(ctx, browserID, kernel.BrowserPlaywrightExecuteParams{ + Code: claude.CheckStatusScript, + TimeoutSec: kernel.Opt(int64(30)), + }) + if err != nil { + pterm.Error.Printf("Status check failed: %v\n", err) + return true, false + } + + var status struct { + ExtensionLoaded bool `json:"extensionLoaded"` + Authenticated bool `json:"authenticated"` + HasConversation bool `json:"hasConversation"` + Error string `json:"error"` + } + if result.Result != nil { + resultBytes, _ := json.Marshal(result.Result) + _ = json.Unmarshal(resultBytes, &status) + } + + pterm.Info.Printf("Extension: %v, Auth: %v, Conversation: %v\n", + status.ExtensionLoaded, status.Authenticated, status.HasConversation) + if status.Error != "" { + pterm.Warning.Printf("Error: %s\n", status.Error) + } + return true, false + + case "/help", "/?": + pterm.Println() + pterm.Info.Println("Available commands:") + pterm.Println(" /quit, /exit - Exit the chat session") + pterm.Println(" /clear - Clear the terminal") + pterm.Println(" /status - Check extension status") + pterm.Println(" /help - Show this help message") + pterm.Println() + return true, false + + default: + pterm.Warning.Printf("Unknown command: %s (use /help for available commands)\n", cmd) + return true, false + } +} + +func sendChatMessage(ctx context.Context, client kernel.Client, browserID, message string) (string, error) { + // Build the script with the message + script := fmt.Sprintf(` +process.env.CLAUDE_MESSAGE = %s; +process.env.CLAUDE_TIMEOUT_MS = '300000'; + +%s +`, jsonMarshalString(message), claude.SendMessageScript) + + result, err := client.Browsers.Playwright.Execute(ctx, browserID, kernel.BrowserPlaywrightExecuteParams{ + Code: script, + TimeoutSec: kernel.Opt(int64(330)), // 5.5 minutes + }) + if err != nil { + return "", fmt.Errorf("failed to send message: %w", err) + } + + if !result.Success { + if result.Error != "" { + return "", fmt.Errorf("%s", result.Error) + } + return "", fmt.Errorf("send failed") + } + + // Parse the result + var response struct { + Response string `json:"response"` + Warning string `json:"warning"` + } + if result.Result != nil { + resultBytes, _ := json.Marshal(result.Result) + _ = json.Unmarshal(resultBytes, &response) + } + + if response.Response == "" { + return "", fmt.Errorf("empty response") + } + + return response.Response, nil +} diff --git a/cmd/claude/claude.go b/cmd/claude/claude.go new file mode 100644 index 0000000..ac76fc0 --- /dev/null +++ b/cmd/claude/claude.go @@ -0,0 +1,51 @@ +// Package claude provides commands for interacting with the Claude for Chrome extension +// in Kernel browsers. +package claude + +import ( + "github.com/spf13/cobra" +) + +// ClaudeCmd is the parent command for Claude extension operations +var ClaudeCmd = &cobra.Command{ + Use: "claude", + Short: "Interact with Claude for Chrome extension in Kernel browsers", + Long: `Commands for using the Claude for Chrome extension in Kernel browsers. + +This command group provides a complete workflow for: +- Extracting the Claude extension from your local Chrome installation +- Launching Kernel browsers with the extension pre-loaded +- Sending messages and interacting with Claude programmatically + +Example workflow: + # Extract extension from local Chrome (run on your machine) + kernel claude extract -o claude-bundle.zip + + # Transfer to server if needed + scp claude-bundle.zip server:~/ + + # Launch a browser with Claude + kernel claude launch -b claude-bundle.zip + + # Send a message + kernel claude send "Hello Claude!" + + # Start interactive chat + kernel claude chat + +For more info: https://docs.onkernel.com/claude`, + Run: func(cmd *cobra.Command, args []string) { + // Show help if called without subcommands + _ = cmd.Help() + }, +} + +func init() { + // Register subcommands + ClaudeCmd.AddCommand(extractCmd) + ClaudeCmd.AddCommand(launchCmd) + ClaudeCmd.AddCommand(loadCmd) + ClaudeCmd.AddCommand(statusCmd) + ClaudeCmd.AddCommand(sendCmd) + ClaudeCmd.AddCommand(chatCmd) +} diff --git a/cmd/claude/extract.go b/cmd/claude/extract.go new file mode 100644 index 0000000..53a0895 --- /dev/null +++ b/cmd/claude/extract.go @@ -0,0 +1,151 @@ +package claude + +import ( + "fmt" + "path/filepath" + + "github.com/onkernel/cli/internal/claude" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var extractCmd = &cobra.Command{ + Use: "extract", + Short: "Extract Claude extension from local Chrome", + Long: `Extract the Claude for Chrome extension and its authentication data from your +local Chrome installation. + +This creates a bundle zip file that can be used with 'kernel claude launch' or +'kernel claude load' to load the extension into a Kernel browser. + +The bundle includes: +- The extension files (manifest.json, scripts, etc.) +- Authentication storage (optional, enabled by default) + +By default, the extension is extracted from Chrome's Default profile. Use +--chrome-profile to specify a different profile if you have multiple Chrome +profiles.`, + Example: ` # Extract with default settings + kernel claude extract + + # Extract to a specific file + kernel claude extract -o my-claude-bundle.zip + + # Extract from a specific Chrome profile + kernel claude extract --chrome-profile "Profile 1" + + # Extract without authentication (will require login) + kernel claude extract --no-auth`, + RunE: runExtract, +} + +func init() { + extractCmd.Flags().StringP("output", "o", claude.DefaultBundleName, "Output path for the bundle zip file") + extractCmd.Flags().String("chrome-profile", "Default", "Chrome profile name to extract from") + extractCmd.Flags().Bool("no-auth", false, "Skip authentication storage (extension will require login)") + extractCmd.Flags().Bool("list-profiles", false, "List available Chrome profiles and exit") +} + +func runExtract(cmd *cobra.Command, args []string) error { + listProfiles, _ := cmd.Flags().GetBool("list-profiles") + + if listProfiles { + return listChromeProfiles() + } + + outputPath, _ := cmd.Flags().GetString("output") + chromeProfile, _ := cmd.Flags().GetString("chrome-profile") + noAuth, _ := cmd.Flags().GetBool("no-auth") + + // Make output path absolute + absOutput, err := filepath.Abs(outputPath) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + + pterm.Info.Printf("Extracting Claude extension from Chrome profile: %s\n", chromeProfile) + + // Check if extension exists + extPath, err := claude.GetChromeExtensionPath(chromeProfile) + if err != nil { + pterm.Error.Printf("Could not find Claude extension: %v\n", err) + pterm.Info.Println("Make sure:") + pterm.Info.Println(" 1. Chrome is installed") + pterm.Info.Println(" 2. The Claude for Chrome extension is installed") + pterm.Info.Println(" 3. You're using the correct Chrome profile (use --list-profiles to see available profiles)") + return nil + } + + pterm.Info.Printf("Found extension at: %s\n", extPath) + + // Check auth storage + includeAuth := !noAuth + if includeAuth { + authPath, err := claude.GetChromeAuthStoragePath(chromeProfile) + if err != nil { + pterm.Warning.Printf("Could not find auth storage: %v\n", err) + pterm.Info.Println("The bundle will be created without authentication.") + pterm.Info.Println("You will need to log in after loading the extension.") + includeAuth = false + } else { + pterm.Info.Printf("Found auth storage at: %s\n", authPath) + } + } else { + pterm.Info.Println("Skipping auth storage (--no-auth specified)") + } + + // Create the bundle + pterm.Info.Printf("Creating bundle: %s\n", absOutput) + + if err := claude.CreateBundle(absOutput, chromeProfile, includeAuth); err != nil { + return fmt.Errorf("failed to create bundle: %w", err) + } + + pterm.Success.Printf("Bundle created: %s\n", absOutput) + + if includeAuth { + pterm.Info.Println("The bundle includes authentication - Claude should be pre-logged-in.") + } else { + pterm.Warning.Println("The bundle does not include authentication - you will need to log in.") + } + + pterm.Println() + pterm.Info.Println("Next steps:") + pterm.Println(" # Launch a browser with the extension") + pterm.Printf(" kernel claude launch -b %s\n", outputPath) + pterm.Println() + pterm.Println(" # Or load into an existing browser") + pterm.Printf(" kernel claude load -b %s\n", outputPath) + + return nil +} + +func listChromeProfiles() error { + pterm.Info.Println("Searching for Chrome profiles...") + + profiles, err := claude.ListChromeProfiles() + if err != nil { + return fmt.Errorf("failed to list Chrome profiles: %w", err) + } + + if len(profiles) == 0 { + pterm.Warning.Println("No Chrome profiles found") + return nil + } + + pterm.Success.Printf("Found %d Chrome profile(s):\n", len(profiles)) + for _, profile := range profiles { + // Check if Claude extension is installed in this profile + _, err := claude.GetChromeExtensionPath(profile) + hasClaude := err == nil + + status := "" + if hasClaude { + status = " (Claude extension installed)" + } + + pterm.Printf(" - %s%s\n", profile, status) + } + + return nil +} diff --git a/cmd/claude/launch.go b/cmd/claude/launch.go new file mode 100644 index 0000000..3c33555 --- /dev/null +++ b/cmd/claude/launch.go @@ -0,0 +1,192 @@ +package claude + +import ( + "context" + "fmt" + + "github.com/onkernel/cli/internal/claude" + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var launchCmd = &cobra.Command{ + Use: "launch", + Short: "Create a browser with Claude extension loaded", + Long: `Create a new Kernel browser session with the Claude for Chrome extension +pre-loaded and authenticated. + +This command: +1. Creates a new browser session +2. Uploads the Claude extension and authentication data +3. Loads the extension (browser will restart) +4. Returns the browser ID and live view URL + +The browser will have Claude ready to use. You can then interact with it using +'kernel claude send' or 'kernel claude chat'.`, + Example: ` # Launch with default settings + kernel claude launch -b claude-bundle.zip + + # Launch with longer timeout + kernel claude launch -b claude-bundle.zip -t 3600 + + # Launch in stealth mode + kernel claude launch -b claude-bundle.zip --stealth + + # Launch and immediately start chatting + kernel claude launch -b claude-bundle.zip --chat`, + RunE: runLaunch, +} + +func init() { + launchCmd.Flags().StringP("bundle", "b", "", "Path to the Claude bundle zip file (required)") + launchCmd.Flags().IntP("timeout", "t", 600, "Session timeout in seconds") + launchCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode") + launchCmd.Flags().BoolP("headless", "H", false, "Launch browser in headless mode") + launchCmd.Flags().String("url", "https://claude.ai", "Initial URL to navigate to") + launchCmd.Flags().Bool("chat", false, "Start interactive chat after launch") + launchCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25)") + + _ = launchCmd.MarkFlagRequired("bundle") +} + +func runLaunch(cmd *cobra.Command, args []string) error { + bundlePath, _ := cmd.Flags().GetString("bundle") + timeout, _ := cmd.Flags().GetInt("timeout") + stealth, _ := cmd.Flags().GetBool("stealth") + headless, _ := cmd.Flags().GetBool("headless") + startURL, _ := cmd.Flags().GetString("url") + startChat, _ := cmd.Flags().GetBool("chat") + viewport, _ := cmd.Flags().GetString("viewport") + + ctx := cmd.Context() + client := util.GetKernelClient(cmd) + + // Extract the bundle + pterm.Info.Printf("Extracting bundle: %s\n", bundlePath) + bundle, err := claude.ExtractBundle(bundlePath) + if err != nil { + return fmt.Errorf("failed to extract bundle: %w", err) + } + defer bundle.Cleanup() + + if bundle.HasAuthStorage() { + pterm.Info.Println("Bundle includes authentication data") + } else { + pterm.Warning.Println("Bundle does not include authentication - login will be required") + } + + // Create the browser session + pterm.Info.Println("Creating browser session...") + browserParams := kernel.BrowserNewParams{ + TimeoutSeconds: kernel.Opt(int64(timeout)), + } + + if stealth { + browserParams.Stealth = kernel.Opt(true) + } + if headless { + browserParams.Headless = kernel.Opt(true) + } + if viewport != "" { + width, height, refreshRate, err := parseViewport(viewport) + if err != nil { + return fmt.Errorf("invalid viewport: %w", err) + } + browserParams.Viewport = kernel.BrowserViewportParam{ + Width: width, + Height: height, + } + if refreshRate > 0 { + browserParams.Viewport.RefreshRate = kernel.Opt(refreshRate) + } + } + + browser, err := client.Browsers.New(ctx, browserParams) + if err != nil { + return fmt.Errorf("failed to create browser: %w", err) + } + + pterm.Info.Printf("Created browser: %s\n", browser.SessionID) + + // Load the Claude extension + pterm.Info.Println("Loading Claude extension...") + if err := claude.LoadIntoBrowser(ctx, claude.LoadIntoBrowserOptions{ + BrowserID: browser.SessionID, + Bundle: bundle, + Client: client, + }); err != nil { + // Try to clean up the browser on failure + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + return fmt.Errorf("failed to load extension: %w", err) + } + + pterm.Success.Println("Claude extension loaded successfully!") + + // Navigate to initial URL if specified + if startURL != "" { + pterm.Info.Printf("Navigating to: %s\n", startURL) + _, err := client.Browsers.Playwright.Execute(ctx, browser.SessionID, kernel.BrowserPlaywrightExecuteParams{ + Code: fmt.Sprintf(`await page.goto('%s');`, startURL), + }) + if err != nil { + pterm.Warning.Printf("Failed to navigate to URL: %v\n", err) + } + } + + // Display results + pterm.Println() + tableData := pterm.TableData{ + {"Property", "Value"}, + {"Browser ID", browser.SessionID}, + {"Live View URL", browser.BrowserLiveViewURL}, + {"CDP WebSocket URL", browser.CdpWsURL}, + {"Timeout (seconds)", fmt.Sprintf("%d", timeout)}, + } + if bundle.HasAuthStorage() { + tableData = append(tableData, []string{"Auth Status", "Pre-authenticated"}) + } else { + tableData = append(tableData, []string{"Auth Status", "Login required"}) + } + + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + + pterm.Println() + pterm.Info.Println("Next steps:") + pterm.Printf(" # Send a message\n") + pterm.Printf(" kernel claude send %s \"Hello Claude!\"\n", browser.SessionID) + pterm.Println() + pterm.Printf(" # Start interactive chat\n") + pterm.Printf(" kernel claude chat %s\n", browser.SessionID) + pterm.Println() + pterm.Printf(" # Check extension status\n") + pterm.Printf(" kernel claude status %s\n", browser.SessionID) + + // Start interactive chat if requested + if startChat { + pterm.Println() + return runChatWithBrowser(ctx, client, browser.SessionID) + } + + return nil +} + +// parseViewport parses a viewport string like "1920x1080@25" into width, height, and refresh rate. +func parseViewport(viewport string) (int64, int64, int64, error) { + var width, height, refreshRate int64 + + // Try parsing with refresh rate + n, err := fmt.Sscanf(viewport, "%dx%d@%d", &width, &height, &refreshRate) + if err == nil && n == 3 { + return width, height, refreshRate, nil + } + + // Try parsing without refresh rate + n, err = fmt.Sscanf(viewport, "%dx%d", &width, &height) + if err == nil && n == 2 { + return width, height, 0, nil + } + + return 0, 0, 0, fmt.Errorf("invalid format, expected WIDTHxHEIGHT[@RATE]") +} diff --git a/cmd/claude/load.go b/cmd/claude/load.go new file mode 100644 index 0000000..452fa29 --- /dev/null +++ b/cmd/claude/load.go @@ -0,0 +1,104 @@ +package claude + +import ( + "fmt" + + "github.com/onkernel/cli/internal/claude" + "github.com/onkernel/cli/pkg/util" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var loadCmd = &cobra.Command{ + Use: "load ", + Short: "Load Claude extension into existing browser", + Long: `Load the Claude for Chrome extension into an existing Kernel browser session. + +This command: +1. Uploads the Claude extension and authentication data +2. Loads the extension (browser will restart) + +Use this if you already have a browser session running and want to add the +Claude extension to it. + +Note: Loading an extension will restart the browser, which may interrupt any +ongoing operations.`, + Example: ` # Load into existing browser + kernel claude load abc123xyz -b claude-bundle.zip`, + Args: cobra.ExactArgs(1), + RunE: runLoad, +} + +func init() { + loadCmd.Flags().StringP("bundle", "b", "", "Path to the Claude bundle zip file (required)") + + _ = loadCmd.MarkFlagRequired("bundle") +} + +func runLoad(cmd *cobra.Command, args []string) error { + browserID := args[0] + bundlePath, _ := cmd.Flags().GetString("bundle") + + ctx := cmd.Context() + client := util.GetKernelClient(cmd) + + // Verify the browser exists + pterm.Info.Printf("Verifying browser: %s\n", browserID) + browser, err := client.Browsers.Get(ctx, browserID) + if err != nil { + return fmt.Errorf("failed to get browser: %w", err) + } + + pterm.Info.Printf("Browser found: %s\n", browser.SessionID) + + // Extract the bundle + pterm.Info.Printf("Extracting bundle: %s\n", bundlePath) + bundle, err := claude.ExtractBundle(bundlePath) + if err != nil { + return fmt.Errorf("failed to extract bundle: %w", err) + } + defer bundle.Cleanup() + + if bundle.HasAuthStorage() { + pterm.Info.Println("Bundle includes authentication data") + } else { + pterm.Warning.Println("Bundle does not include authentication - login will be required") + } + + // Load the Claude extension + pterm.Info.Println("Loading Claude extension (browser will restart)...") + if err := claude.LoadIntoBrowser(ctx, claude.LoadIntoBrowserOptions{ + BrowserID: browser.SessionID, + Bundle: bundle, + Client: client, + }); err != nil { + return fmt.Errorf("failed to load extension: %w", err) + } + + pterm.Success.Println("Claude extension loaded successfully!") + + // Display results + pterm.Println() + tableData := pterm.TableData{ + {"Property", "Value"}, + {"Browser ID", browser.SessionID}, + {"Live View URL", browser.BrowserLiveViewURL}, + } + if bundle.HasAuthStorage() { + tableData = append(tableData, []string{"Auth Status", "Pre-authenticated"}) + } else { + tableData = append(tableData, []string{"Auth Status", "Login required"}) + } + + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + + pterm.Println() + pterm.Info.Println("Next steps:") + pterm.Printf(" # Check extension status\n") + pterm.Printf(" kernel claude status %s\n", browser.SessionID) + pterm.Println() + pterm.Printf(" # Send a message\n") + pterm.Printf(" kernel claude send %s \"Hello Claude!\"\n", browser.SessionID) + + return nil +} diff --git a/cmd/claude/send.go b/cmd/claude/send.go new file mode 100644 index 0000000..73e28ad --- /dev/null +++ b/cmd/claude/send.go @@ -0,0 +1,185 @@ +package claude + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/onkernel/cli/internal/claude" + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var sendCmd = &cobra.Command{ + Use: "send [message]", + Short: "Send a message to Claude", + Long: `Send a single message to Claude and get the response. + +The message can be provided as: +- A command line argument +- From stdin (piped input) +- From a file (using --file) + +This command is designed for scripting and automation. For interactive +conversations, use 'kernel claude chat' instead.`, + Example: ` # Send a message as argument + kernel claude send abc123 "What is 2+2?" + + # Pipe a message from stdin + echo "Explain this error" | kernel claude send abc123 + + # Read message from a file + kernel claude send abc123 -f prompt.txt + + # Output as JSON for scripting + kernel claude send abc123 "Hello" --json`, + Args: cobra.MinimumNArgs(1), + RunE: runSend, +} + +func init() { + sendCmd.Flags().StringP("file", "f", "", "Read message from file") + sendCmd.Flags().Int("timeout", 120, "Response timeout in seconds") + sendCmd.Flags().Bool("json", false, "Output response as JSON") + sendCmd.Flags().Bool("raw", false, "Output raw response without formatting") +} + +// SendResponse represents the JSON output of the send command. +type SendResponse struct { + Response string `json:"response"` + Warning string `json:"warning,omitempty"` +} + +func runSend(cmd *cobra.Command, args []string) error { + browserID := args[0] + filePath, _ := cmd.Flags().GetString("file") + timeout, _ := cmd.Flags().GetInt("timeout") + jsonOutput, _ := cmd.Flags().GetBool("json") + rawOutput, _ := cmd.Flags().GetBool("raw") + + ctx := cmd.Context() + client := util.GetKernelClient(cmd) + + // Get the message from various sources + var message string + var err error + + if len(args) > 1 { + // Message from command line arguments + message = strings.Join(args[1:], " ") + } else if filePath != "" { + // Message from file + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + message = strings.TrimSpace(string(content)) + } else { + // Check for stdin + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + // Stdin has data + reader := bufio.NewReader(os.Stdin) + content, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + message = strings.TrimSpace(string(content)) + } + } + + if message == "" { + return fmt.Errorf("no message provided. Provide a message as an argument, via stdin, or with --file") + } + + // Verify the browser exists + if !jsonOutput && !rawOutput { + pterm.Info.Printf("Sending message to browser: %s\n", browserID) + } + + browser, err := client.Browsers.Get(ctx, browserID) + if err != nil { + return fmt.Errorf("failed to get browser: %w", err) + } + + // Build the script with environment variables + script := fmt.Sprintf(` +process.env.CLAUDE_MESSAGE = %s; +process.env.CLAUDE_TIMEOUT_MS = '%d'; + +%s +`, jsonMarshalString(message), timeout*1000, claude.SendMessageScript) + + // Execute the send message script + if !jsonOutput && !rawOutput { + pterm.Info.Println("Sending message...") + } + + result, err := client.Browsers.Playwright.Execute(ctx, browser.SessionID, kernel.BrowserPlaywrightExecuteParams{ + Code: script, + TimeoutSec: kernel.Opt(int64(timeout + 30)), // Add buffer for script setup + }) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + if !result.Success { + if result.Error != "" { + return fmt.Errorf("send failed: %s", result.Error) + } + return fmt.Errorf("send failed") + } + + // Parse the result + var response SendResponse + if result.Result != nil { + resultBytes, err := json.Marshal(result.Result) + if err != nil { + return fmt.Errorf("failed to parse result: %w", err) + } + if err := json.Unmarshal(resultBytes, &response); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + } + + // Output the response + if jsonOutput { + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + fmt.Println(string(output)) + return nil + } + + if rawOutput { + fmt.Print(response.Response) + return nil + } + + // Formatted output + pterm.Println() + if response.Warning != "" { + pterm.Warning.Println(response.Warning) + } + pterm.Success.Println("Response:") + pterm.Println() + fmt.Println(response.Response) + + return nil +} + +// jsonMarshalString returns a JSON-encoded string suitable for embedding in JavaScript. +func jsonMarshalString(s string) string { + b, err := json.Marshal(s) + if err != nil { + // Fallback to simple escaping + return fmt.Sprintf(`"%s"`, strings.ReplaceAll(s, `"`, `\"`)) + } + return string(b) +} diff --git a/cmd/claude/status.go b/cmd/claude/status.go new file mode 100644 index 0000000..d8f141a --- /dev/null +++ b/cmd/claude/status.go @@ -0,0 +1,154 @@ +package claude + +import ( + "encoding/json" + "fmt" + + "github.com/onkernel/cli/internal/claude" + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status ", + Short: "Check Claude extension status", + Long: `Check the status of the Claude for Chrome extension in a Kernel browser. + +This command checks: +- Whether the extension is loaded +- Whether the user is authenticated +- Whether there are any errors +- Whether there's an active conversation`, + Example: ` kernel claude status abc123xyz`, + Args: cobra.ExactArgs(1), + RunE: runStatus, +} + +func init() { + statusCmd.Flags().StringP("output", "o", "", "Output format: json for raw response") +} + +// StatusResult represents the result of a status check. +type StatusResult struct { + ExtensionLoaded bool `json:"extensionLoaded"` + Authenticated bool `json:"authenticated"` + HasConversation bool `json:"hasConversation"` + Error string `json:"error,omitempty"` +} + +func runStatus(cmd *cobra.Command, args []string) error { + browserID := args[0] + outputFormat, _ := cmd.Flags().GetString("output") + + ctx := cmd.Context() + client := util.GetKernelClient(cmd) + + // Verify the browser exists + if outputFormat != "json" { + pterm.Info.Printf("Checking browser: %s\n", browserID) + } + + browser, err := client.Browsers.Get(ctx, browserID) + if err != nil { + return fmt.Errorf("failed to get browser: %w", err) + } + + // Execute the status check script + if outputFormat != "json" { + pterm.Info.Println("Checking Claude extension status...") + } + + result, err := client.Browsers.Playwright.Execute(ctx, browser.SessionID, kernel.BrowserPlaywrightExecuteParams{ + Code: claude.CheckStatusScript, + TimeoutSec: kernel.Opt(int64(30)), + }) + if err != nil { + return fmt.Errorf("failed to check status: %w", err) + } + + if !result.Success { + if result.Error != "" { + return fmt.Errorf("status check failed: %s", result.Error) + } + return fmt.Errorf("status check failed") + } + + // Parse the result + var status StatusResult + if result.Result != nil { + resultBytes, err := json.Marshal(result.Result) + if err != nil { + return fmt.Errorf("failed to parse result: %w", err) + } + if err := json.Unmarshal(resultBytes, &status); err != nil { + return fmt.Errorf("failed to parse status: %w", err) + } + } + + // Output results + if outputFormat == "json" { + output, err := json.MarshalIndent(status, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + fmt.Println(string(output)) + return nil + } + + // Table output + pterm.Println() + tableData := pterm.TableData{ + {"Property", "Status"}, + } + + // Extension status + if status.ExtensionLoaded { + tableData = append(tableData, []string{"Extension", pterm.Green("Loaded")}) + } else { + tableData = append(tableData, []string{"Extension", pterm.Red("Not Loaded")}) + } + + // Auth status + if status.Authenticated { + tableData = append(tableData, []string{"Authentication", pterm.Green("Authenticated")}) + } else { + tableData = append(tableData, []string{"Authentication", pterm.Yellow("Not Authenticated")}) + } + + // Conversation status + if status.HasConversation { + tableData = append(tableData, []string{"Conversation", "Active"}) + } else { + tableData = append(tableData, []string{"Conversation", "None"}) + } + + // Error status + if status.Error != "" { + tableData = append(tableData, []string{"Error", pterm.Red(status.Error)}) + } + + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + + // Provide next steps based on status + pterm.Println() + if !status.ExtensionLoaded { + pterm.Warning.Println("Extension is not loaded. Try loading it with:") + pterm.Printf(" kernel claude load %s -b claude-bundle.zip\n", browserID) + } else if !status.Authenticated { + pterm.Warning.Println("Extension is not authenticated. You need to log in manually via the live view:") + pterm.Printf(" Open: %s\n", browser.BrowserLiveViewURL) + } else { + pterm.Success.Println("Claude is ready!") + pterm.Println() + pterm.Info.Println("You can:") + pterm.Printf(" # Send a message\n") + pterm.Printf(" kernel claude send %s \"Hello Claude!\"\n", browserID) + pterm.Println() + pterm.Printf(" # Start interactive chat\n") + pterm.Printf(" kernel claude chat %s\n", browserID) + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 1b4a6b3..b3158cd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/fang" "github.com/charmbracelet/lipgloss/v2" + "github.com/onkernel/cli/cmd/claude" "github.com/onkernel/cli/cmd/mcp" "github.com/onkernel/cli/cmd/proxies" "github.com/onkernel/cli/pkg/auth" @@ -141,6 +142,7 @@ func init() { rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) + rootCmd.AddCommand(claude.ClaudeCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command diff --git a/internal/claude/bundle.go b/internal/claude/bundle.go new file mode 100644 index 0000000..3bc4b6e --- /dev/null +++ b/internal/claude/bundle.go @@ -0,0 +1,249 @@ +package claude + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Bundle represents an extracted Claude extension bundle. +type Bundle struct { + // ExtensionPath is the path to the extracted extension directory + ExtensionPath string + + // AuthStoragePath is the path to the extracted auth storage directory (may be empty if no auth) + AuthStoragePath string + + // TempDir is the temporary directory containing the extracted bundle (for cleanup) + TempDir string +} + +// Cleanup removes the temporary directory containing the extracted bundle. +func (b *Bundle) Cleanup() { + if b.TempDir != "" { + os.RemoveAll(b.TempDir) + } +} + +// CreateBundle creates a zip bundle from Chrome's Claude extension and optionally its auth storage. +func CreateBundle(outputPath string, chromeProfile string, includeAuth bool) error { + // Get extension path + extPath, err := GetChromeExtensionPath(chromeProfile) + if err != nil { + return fmt.Errorf("failed to locate Claude extension: %w", err) + } + + // Get auth storage path (optional) + var authPath string + if includeAuth { + authPath, err = GetChromeAuthStoragePath(chromeProfile) + if err != nil { + // Auth storage is optional - warn but continue + fmt.Printf("Warning: Could not locate auth storage: %v\n", err) + authPath = "" + } + } + + // Create the output zip file + zipFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create bundle file: %w", err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add extension files under "extension/" prefix + if err := addDirectoryToZip(zipWriter, extPath, BundleExtensionDir); err != nil { + return fmt.Errorf("failed to add extension to bundle: %w", err) + } + + // Add auth storage files under "auth-storage/" prefix (if available) + if authPath != "" { + if err := addDirectoryToZip(zipWriter, authPath, BundleAuthStorageDir); err != nil { + return fmt.Errorf("failed to add auth storage to bundle: %w", err) + } + } + + return nil +} + +// ExtractBundle extracts a bundle zip file to a temporary directory. +// Returns a Bundle struct with paths to the extracted directories. +// The caller is responsible for calling Bundle.Cleanup() when done. +func ExtractBundle(bundlePath string) (*Bundle, error) { + // Create temporary directory + tempDir, err := os.MkdirTemp("", "claude-bundle-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + // Extract the zip + if err := unzip(bundlePath, tempDir); err != nil { + os.RemoveAll(tempDir) + return nil, fmt.Errorf("failed to extract bundle: %w", err) + } + + bundle := &Bundle{ + TempDir: tempDir, + } + + // Check for extension directory + extDir := filepath.Join(tempDir, BundleExtensionDir) + if _, err := os.Stat(extDir); err == nil { + bundle.ExtensionPath = extDir + } else { + os.RemoveAll(tempDir) + return nil, fmt.Errorf("bundle does not contain extension directory") + } + + // Check for auth storage directory (optional) + authDir := filepath.Join(tempDir, BundleAuthStorageDir) + if _, err := os.Stat(authDir); err == nil { + bundle.AuthStoragePath = authDir + } + + return bundle, nil +} + +// HasAuthStorage returns true if the bundle contains auth storage. +func (b *Bundle) HasAuthStorage() bool { + return b.AuthStoragePath != "" +} + +// addDirectoryToZip adds all files from a directory to a zip archive under the given prefix. +func addDirectoryToZip(zipWriter *zip.Writer, srcDir, prefix string) error { + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself + if path == srcDir { + return nil + } + + // Compute relative path + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Convert to forward slashes and add prefix + zipPath := filepath.ToSlash(filepath.Join(prefix, relPath)) + + if info.IsDir() { + // Add directory entry + _, err := zipWriter.Create(zipPath + "/") + return err + } + + // Handle symlinks + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(path) + if err != nil { + return err + } + + header := &zip.FileHeader{ + Name: zipPath, + Method: zip.Store, + } + header.SetMode(os.ModeSymlink | 0777) + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + _, err = writer.Write([]byte(linkTarget)) + return err + } + + // Regular file + writer, err := zipWriter.Create(zipPath) + if err != nil { + return err + } + + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) +} + +// unzip extracts a zip file to the destination directory. +func unzip(zipPath, destDir string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip file: %w", err) + } + defer reader.Close() + + for _, file := range reader.File { + destPath := filepath.Join(destDir, file.Name) + + // Security check: prevent zip slip + if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", file.Name) + } + + if file.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return err + } + continue + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return err + } + + // Handle symlinks + if file.Mode()&os.ModeSymlink != 0 { + fileReader, err := file.Open() + if err != nil { + return err + } + linkTarget, err := io.ReadAll(fileReader) + fileReader.Close() + if err != nil { + return err + } + if err := os.Symlink(string(linkTarget), destPath); err != nil { + return err + } + continue + } + + // Regular file + destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return err + } + + fileReader, err := file.Open() + if err != nil { + destFile.Close() + return err + } + + _, err = io.Copy(destFile, fileReader) + fileReader.Close() + destFile.Close() + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/claude/bundle_test.go b/internal/claude/bundle_test.go new file mode 100644 index 0000000..17cc5fa --- /dev/null +++ b/internal/claude/bundle_test.go @@ -0,0 +1,181 @@ +package claude + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractBundle(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "claude-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a zip of the bundle manually since CreateBundle requires Chrome + zipPath := filepath.Join(tempDir, "bundle.zip") + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + + zipWriter := zip.NewWriter(zipFile) + + // Add extension directory + _, err = zipWriter.Create(BundleExtensionDir + "/") + require.NoError(t, err) + + w, err := zipWriter.Create(BundleExtensionDir + "/manifest.json") + require.NoError(t, err) + _, err = w.Write([]byte(`{"name": "Claude"}`)) + require.NoError(t, err) + + // Add auth directory + _, err = zipWriter.Create(BundleAuthStorageDir + "/") + require.NoError(t, err) + + w, err = zipWriter.Create(BundleAuthStorageDir + "/CURRENT") + require.NoError(t, err) + _, err = w.Write([]byte("test")) + require.NoError(t, err) + + require.NoError(t, zipWriter.Close()) + require.NoError(t, zipFile.Close()) + + // Test extraction + bundle, err := ExtractBundle(zipPath) + require.NoError(t, err) + defer bundle.Cleanup() + + assert.NotEmpty(t, bundle.ExtensionPath) + assert.NotEmpty(t, bundle.AuthStoragePath) + assert.True(t, bundle.HasAuthStorage()) + + // Verify files exist + manifestPath := filepath.Join(bundle.ExtensionPath, "manifest.json") + _, err = os.Stat(manifestPath) + assert.NoError(t, err) + + currentPath := filepath.Join(bundle.AuthStoragePath, "CURRENT") + _, err = os.Stat(currentPath) + assert.NoError(t, err) +} + +func TestExtractBundleWithoutAuth(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "claude-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a zip without auth storage + zipPath := filepath.Join(tempDir, "bundle-no-auth.zip") + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + + zipWriter := zip.NewWriter(zipFile) + + // Add extension directory only + _, err = zipWriter.Create(BundleExtensionDir + "/") + require.NoError(t, err) + + w, err := zipWriter.Create(BundleExtensionDir + "/manifest.json") + require.NoError(t, err) + _, err = w.Write([]byte(`{"name": "Claude"}`)) + require.NoError(t, err) + + require.NoError(t, zipWriter.Close()) + require.NoError(t, zipFile.Close()) + + // Test extraction + bundle, err := ExtractBundle(zipPath) + require.NoError(t, err) + defer bundle.Cleanup() + + assert.NotEmpty(t, bundle.ExtensionPath) + assert.Empty(t, bundle.AuthStoragePath) + assert.False(t, bundle.HasAuthStorage()) +} + +func TestExtractBundleMissingExtension(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "claude-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create an empty zip (just the zip structure, no directories) + zipPath := filepath.Join(tempDir, "empty.zip") + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + + zipWriter := zip.NewWriter(zipFile) + require.NoError(t, zipWriter.Close()) + require.NoError(t, zipFile.Close()) + + // Test extraction should fail + _, err = ExtractBundle(zipPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "extension directory") +} + +func TestBundleCleanup(t *testing.T) { + // Create a temporary directory manually + tempDir, err := os.MkdirTemp("", "claude-cleanup-test-*") + require.NoError(t, err) + + bundle := &Bundle{ + TempDir: tempDir, + ExtensionPath: filepath.Join(tempDir, BundleExtensionDir), + } + + // Create the extension path + require.NoError(t, os.MkdirAll(bundle.ExtensionPath, 0755)) + + // Verify it exists + _, err = os.Stat(tempDir) + require.NoError(t, err) + + // Cleanup + bundle.Cleanup() + + // Verify it's gone + _, err = os.Stat(tempDir) + assert.True(t, os.IsNotExist(err)) +} + +func TestUnzipSecurityCheck(t *testing.T) { + // Create a temporary directory + tempDir, err := os.MkdirTemp("", "claude-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create a malicious zip with path traversal attempt + zipPath := filepath.Join(tempDir, "malicious.zip") + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + + zipWriter := zip.NewWriter(zipFile) + + // Try to add a file with path traversal + _, err = zipWriter.Create("../../../etc/passwd") + require.NoError(t, err) + + require.NoError(t, zipWriter.Close()) + require.NoError(t, zipFile.Close()) + + // Extraction should fail due to security check + extractDir := filepath.Join(tempDir, "extracted") + err = unzip(zipPath, extractDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "illegal file path") +} + +func TestConstants(t *testing.T) { + // Verify constants are set correctly + assert.Equal(t, "fcoeoabgfenejglbffodgkkbkcdhcgfn", ExtensionID) + assert.Equal(t, "Claude for Chrome", ExtensionName) + assert.Equal(t, "extension", BundleExtensionDir) + assert.Equal(t, "auth-storage", BundleAuthStorageDir) + assert.Contains(t, SidePanelURL, ExtensionID) +} diff --git a/internal/claude/constants.go b/internal/claude/constants.go new file mode 100644 index 0000000..55a3dbb --- /dev/null +++ b/internal/claude/constants.go @@ -0,0 +1,35 @@ +// Package claude provides utilities for working with the Claude for Chrome extension +// in Kernel browsers. +package claude + +const ( + // ExtensionID is the Chrome Web Store ID for Claude for Chrome + ExtensionID = "fcoeoabgfenejglbffodgkkbkcdhcgfn" + + // ExtensionName is the human-readable name of the extension + ExtensionName = "Claude for Chrome" + + // KernelUserDataPath is the path to Chrome's user data directory in Kernel browsers + KernelUserDataPath = "/home/kernel/user-data" + + // KernelDefaultProfilePath is the path to the Default profile in Kernel browsers + KernelDefaultProfilePath = "/home/kernel/user-data/Default" + + // KernelExtensionSettingsPath is where Chrome stores extension LocalStorage/LevelDB data + KernelExtensionSettingsPath = "/home/kernel/user-data/Default/Local Extension Settings" + + // KernelUser is the username that owns the user-data directory in Kernel browsers + KernelUser = "kernel" + + // SidePanelURL is the URL to open the Claude extension sidepanel in window mode + SidePanelURL = "chrome-extension://" + ExtensionID + "/sidepanel.html?mode=window" + + // DefaultBundleName is the default filename for the extension bundle + DefaultBundleName = "claude-bundle.zip" + + // BundleExtensionDir is the directory name for the extension within the bundle + BundleExtensionDir = "extension" + + // BundleAuthStorageDir is the directory name for auth storage within the bundle + BundleAuthStorageDir = "auth-storage" +) diff --git a/internal/claude/loader.go b/internal/claude/loader.go new file mode 100644 index 0000000..a56226b --- /dev/null +++ b/internal/claude/loader.go @@ -0,0 +1,113 @@ +package claude + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/onkernel/cli/pkg/util" + "github.com/onkernel/kernel-go-sdk" +) + +// LoadIntoBrowserOptions configures how the extension is loaded into a browser. +type LoadIntoBrowserOptions struct { + // BrowserID is the Kernel browser session ID + BrowserID string + + // Bundle is the extracted Claude extension bundle + Bundle *Bundle + + // Client is the Kernel API client + Client kernel.Client +} + +// LoadIntoBrowser uploads the Claude extension and auth storage to a Kernel browser. +// This will: +// 1. Upload auth storage (if present) to the browser's user data directory +// 2. Set proper permissions on the auth storage +// 3. Load the extension (which triggers a browser restart) +func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { + fs := opts.Client.Browsers.Fs + + // Step 1: Upload auth storage if present + if opts.Bundle.HasAuthStorage() { + authDestPath := filepath.Join(KernelExtensionSettingsPath, ExtensionID) + + // Create a temp zip of just the auth storage contents + authZipPath, err := createTempZip(opts.Bundle.AuthStoragePath) + if err != nil { + return fmt.Errorf("failed to create auth storage zip: %w", err) + } + defer os.Remove(authZipPath) + + // Upload the auth storage zip + authZipFile, err := os.Open(authZipPath) + if err != nil { + return fmt.Errorf("failed to open auth storage zip: %w", err) + } + defer authZipFile.Close() + + if err := fs.UploadZip(ctx, opts.BrowserID, kernel.BrowserFUploadZipParams{ + DestPath: authDestPath, + ZipFile: authZipFile, + }); err != nil { + return fmt.Errorf("failed to upload auth storage: %w", err) + } + + // Set proper ownership on the auth storage directory + if err := fs.SetFilePermissions(ctx, opts.BrowserID, kernel.BrowserFSetFilePermissionsParams{ + Path: authDestPath, + Mode: "0755", + Owner: kernel.Opt(KernelUser), + Group: kernel.Opt(KernelUser), + }); err != nil { + return fmt.Errorf("failed to set auth storage permissions: %w", err) + } + } + + // Step 2: Upload and load the extension + // Create a temp zip of the extension + extZipPath, err := createTempZip(opts.Bundle.ExtensionPath) + if err != nil { + return fmt.Errorf("failed to create extension zip: %w", err) + } + defer os.Remove(extZipPath) + + extZipFile, err := os.Open(extZipPath) + if err != nil { + return fmt.Errorf("failed to open extension zip: %w", err) + } + defer extZipFile.Close() + + // Use the LoadExtensions API which handles the extension loading and browser restart + if err := opts.Client.Browsers.LoadExtensions(ctx, opts.BrowserID, kernel.BrowserLoadExtensionsParams{ + Extensions: []kernel.BrowserLoadExtensionsParamsExtension{ + { + Name: ExtensionName, + ZipFile: extZipFile, + }, + }, + }); err != nil { + return fmt.Errorf("failed to load extension: %w", err) + } + + return nil +} + +// createTempZip creates a temporary zip file from a directory. +func createTempZip(srcDir string) (string, error) { + tmpFile, err := os.CreateTemp("", "claude-*.zip") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + tmpFile.Close() + + if err := util.ZipDirectory(srcDir, tmpPath); err != nil { + os.Remove(tmpPath) + return "", err + } + + return tmpPath, nil +} diff --git a/internal/claude/paths.go b/internal/claude/paths.go new file mode 100644 index 0000000..afa2f30 --- /dev/null +++ b/internal/claude/paths.go @@ -0,0 +1,157 @@ +package claude + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" +) + +// GetChromeExtensionPath returns the path to the Claude extension directory for the given Chrome profile. +// It automatically detects the OS and returns the appropriate path. +func GetChromeExtensionPath(profile string) (string, error) { + if profile == "" { + profile = "Default" + } + + extensionsDir, err := getChromeExtensionsDir(profile) + if err != nil { + return "", err + } + + extDir := filepath.Join(extensionsDir, ExtensionID) + if _, err := os.Stat(extDir); os.IsNotExist(err) { + return "", fmt.Errorf("Claude extension not found at %s", extDir) + } + + // Find the latest version directory + versionDir, err := findLatestVersionDir(extDir) + if err != nil { + return "", fmt.Errorf("failed to find extension version: %w", err) + } + + return versionDir, nil +} + +// GetChromeAuthStoragePath returns the path to the Claude extension's auth storage (LevelDB). +func GetChromeAuthStoragePath(profile string) (string, error) { + if profile == "" { + profile = "Default" + } + + userDataDir, err := getChromeUserDataDir() + if err != nil { + return "", err + } + + // Extension local storage is stored in "Local Extension Settings/" + authPath := filepath.Join(userDataDir, profile, "Local Extension Settings", ExtensionID) + if _, err := os.Stat(authPath); os.IsNotExist(err) { + return "", fmt.Errorf("Claude extension auth storage not found at %s", authPath) + } + + return authPath, nil +} + +// getChromeUserDataDir returns the Chrome user data directory for the current OS. +func getChromeUserDataDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + var userDataDir string + switch runtime.GOOS { + case "darwin": + userDataDir = filepath.Join(homeDir, "Library", "Application Support", "Google", "Chrome") + case "linux": + userDataDir = filepath.Join(homeDir, ".config", "google-chrome") + case "windows": + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + localAppData = filepath.Join(homeDir, "AppData", "Local") + } + userDataDir = filepath.Join(localAppData, "Google", "Chrome", "User Data") + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + if _, err := os.Stat(userDataDir); os.IsNotExist(err) { + return "", fmt.Errorf("Chrome user data directory not found at %s", userDataDir) + } + + return userDataDir, nil +} + +// getChromeExtensionsDir returns the extensions directory for the given profile. +func getChromeExtensionsDir(profile string) (string, error) { + userDataDir, err := getChromeUserDataDir() + if err != nil { + return "", err + } + + extensionsDir := filepath.Join(userDataDir, profile, "Extensions") + if _, err := os.Stat(extensionsDir); os.IsNotExist(err) { + return "", fmt.Errorf("Chrome extensions directory not found at %s", extensionsDir) + } + + return extensionsDir, nil +} + +// findLatestVersionDir finds the latest version directory within an extension directory. +// Chrome stores extensions in subdirectories named by version (e.g., "1.0.0_0"). +func findLatestVersionDir(extDir string) (string, error) { + entries, err := os.ReadDir(extDir) + if err != nil { + return "", fmt.Errorf("failed to read extension directory: %w", err) + } + + var versions []string + for _, entry := range entries { + if entry.IsDir() && entry.Name() != "" && entry.Name()[0] != '.' { + versions = append(versions, entry.Name()) + } + } + + if len(versions) == 0 { + return "", fmt.Errorf("no version directories found in %s", extDir) + } + + // Sort versions and pick the latest (lexicographic sort works for semver-like versions) + sort.Strings(versions) + latestVersion := versions[len(versions)-1] + + return filepath.Join(extDir, latestVersion), nil +} + +// ListChromeProfiles returns a list of available Chrome profiles. +func ListChromeProfiles() ([]string, error) { + userDataDir, err := getChromeUserDataDir() + if err != nil { + return nil, err + } + + entries, err := os.ReadDir(userDataDir) + if err != nil { + return nil, fmt.Errorf("failed to read Chrome user data directory: %w", err) + } + + var profiles []string + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + // Chrome profiles are named "Default", "Profile 1", "Profile 2", etc. + if name == "Default" || (len(name) > 8 && name[:8] == "Profile ") { + // Check if it's actually a profile by looking for a Preferences file + prefsPath := filepath.Join(userDataDir, name, "Preferences") + if _, err := os.Stat(prefsPath); err == nil { + profiles = append(profiles, name) + } + } + } + + return profiles, nil +} diff --git a/internal/claude/paths_test.go b/internal/claude/paths_test.go new file mode 100644 index 0000000..d13b505 --- /dev/null +++ b/internal/claude/paths_test.go @@ -0,0 +1,127 @@ +package claude + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindLatestVersionDir(t *testing.T) { + // Create a temporary directory structure + tempDir, err := os.MkdirTemp("", "claude-version-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create version directories + versions := []string{"1.0.0_0", "1.0.1_0", "2.0.0_0", "1.5.0_0"} + for _, v := range versions { + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, v), 0755)) + } + + // Should find the latest (2.0.0_0 is lexicographically highest) + latest, err := findLatestVersionDir(tempDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(tempDir, "2.0.0_0"), latest) +} + +func TestFindLatestVersionDirEmpty(t *testing.T) { + // Create an empty directory + tempDir, err := os.MkdirTemp("", "claude-empty-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Should return an error + _, err = findLatestVersionDir(tempDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no version directories") +} + +func TestFindLatestVersionDirSkipsHidden(t *testing.T) { + // Create a temporary directory structure + tempDir, err := os.MkdirTemp("", "claude-hidden-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create version directories including hidden ones + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, ".hidden"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(tempDir, "1.0.0_0"), 0755)) + + // Should find 1.0.0_0, not .hidden + latest, err := findLatestVersionDir(tempDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(tempDir, "1.0.0_0"), latest) +} + +func TestGetChromeUserDataDir(t *testing.T) { + // This test just verifies the function returns a path based on OS + // It will likely fail on CI unless Chrome is installed, so we just + // check that it returns an error with expected message + + _, err := getChromeUserDataDir() + if err != nil { + // Expected error when Chrome is not installed + assert.Contains(t, err.Error(), "Chrome") + } + // If no error, Chrome is installed and we got a valid path +} + +func TestListChromeProfiles(t *testing.T) { + // This test will likely fail on CI unless Chrome is installed + profiles, err := ListChromeProfiles() + if err != nil { + // Expected when Chrome is not installed + t.Logf("Chrome not found: %v", err) + return + } + + // If Chrome is installed, we should have at least one profile + t.Logf("Found Chrome profiles: %v", profiles) +} + +func TestGetChromeExtensionPathNotInstalled(t *testing.T) { + // Claude extension is unlikely to be installed in CI + _, err := GetChromeExtensionPath("Default") + if err != nil { + // Expected - either Chrome not installed or Claude extension not found + assert.Contains(t, err.Error(), "not found") + } +} + +func TestGetChromeAuthStoragePathNotInstalled(t *testing.T) { + // Claude extension is unlikely to be installed in CI + _, err := GetChromeAuthStoragePath("Default") + if err != nil { + // Expected - either Chrome not installed or auth storage not found + assert.Contains(t, err.Error(), "not found") + } +} + +func TestKernelPaths(t *testing.T) { + // Verify the Kernel-specific paths are correct for Linux + assert.Equal(t, "/home/kernel/user-data", KernelUserDataPath) + assert.Equal(t, "/home/kernel/user-data/Default", KernelDefaultProfilePath) + assert.Equal(t, "/home/kernel/user-data/Default/Local Extension Settings", KernelExtensionSettingsPath) + assert.Equal(t, "kernel", KernelUser) +} + +func TestChromeUserDataDirPath(t *testing.T) { + // Just verify the expected path format based on OS + homeDir, err := os.UserHomeDir() + require.NoError(t, err) + + expectedPaths := map[string]string{ + "darwin": filepath.Join(homeDir, "Library", "Application Support", "Google", "Chrome"), + "linux": filepath.Join(homeDir, ".config", "google-chrome"), + "windows": "", // Windows path depends on LOCALAPPDATA env var + } + + if expected, ok := expectedPaths[runtime.GOOS]; ok && expected != "" { + // The function will return error if Chrome isn't installed, + // but the path format should be correct + t.Logf("Expected Chrome path for %s: %s", runtime.GOOS, expected) + } +} diff --git a/internal/claude/scripts.go b/internal/claude/scripts.go new file mode 100644 index 0000000..d62ad46 --- /dev/null +++ b/internal/claude/scripts.go @@ -0,0 +1,17 @@ +package claude + +import ( + _ "embed" +) + +// Embedded Playwright scripts for interacting with the Claude extension. +// These scripts are executed via Kernel's Playwright execution API. + +//go:embed scripts/send_message.js +var SendMessageScript string + +//go:embed scripts/check_status.js +var CheckStatusScript string + +//go:embed scripts/stream_chat.js +var StreamChatScript string diff --git a/internal/claude/scripts/check_status.js b/internal/claude/scripts/check_status.js new file mode 100644 index 0000000..775a3ce --- /dev/null +++ b/internal/claude/scripts/check_status.js @@ -0,0 +1,68 @@ +// Check the status of the Claude extension. +// This script is executed via Kernel's Playwright API. +// +// Output: +// - Returns JSON with extension status information + +const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; +const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; + +async function run({ context }) { + const status = { + extensionLoaded: false, + authenticated: false, + error: null, + hasConversation: false, + }; + + try { + // Try to open the sidepanel + const sidepanel = await context.newPage(); + + try { + await sidepanel.goto(SIDEPANEL_URL, { timeout: 10000 }); + await sidepanel.waitForLoadState('networkidle', { timeout: 10000 }); + status.extensionLoaded = true; + } catch (e) { + status.error = 'Extension not loaded or not accessible'; + return status; + } + + // Wait a bit for the UI to initialize + await sidepanel.waitForTimeout(2000); + + // Check for authentication indicators + // Look for chat input (indicates authenticated) + const chatInput = await sidepanel.$('textarea, [contenteditable="true"]'); + if (chatInput) { + status.authenticated = true; + } + + // Look for login/sign-in elements (indicates not authenticated) + const loginButton = await sidepanel.$('button:has-text("Sign in"), button:has-text("Log in"), a:has-text("Sign in")'); + if (loginButton) { + status.authenticated = false; + } + + // Check for any error messages + const errorElement = await sidepanel.$('[class*="error"], [class*="Error"], [role="alert"]'); + if (errorElement) { + const errorText = await errorElement.textContent(); + status.error = errorText?.trim() || 'Unknown error'; + } + + // Check if there are existing messages (conversation in progress) + const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + status.hasConversation = messages.length > 0; + + // Close the test page + await sidepanel.close(); + + } catch (e) { + status.error = e.message; + } + + return status; +} + +module.exports = run; diff --git a/internal/claude/scripts/send_message.js b/internal/claude/scripts/send_message.js new file mode 100644 index 0000000..1f42a54 --- /dev/null +++ b/internal/claude/scripts/send_message.js @@ -0,0 +1,99 @@ +// Send a message to Claude and wait for the response. +// This script is executed via Kernel's Playwright API. +// +// Input (via environment/args): +// - CLAUDE_MESSAGE: The message to send +// - CLAUDE_TIMEOUT_MS: Timeout in milliseconds (default: 120000) +// +// Output: +// - Returns JSON with { response: string, model?: string } + +const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; +const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; + +async function run({ context }) { + const message = process.env.CLAUDE_MESSAGE; + const timeoutMs = parseInt(process.env.CLAUDE_TIMEOUT_MS || '120000', 10); + + if (!message) { + throw new Error('CLAUDE_MESSAGE environment variable is required'); + } + + // Find or open the sidepanel page + let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); + if (!sidepanel) { + sidepanel = await context.newPage(); + await sidepanel.goto(SIDEPANEL_URL); + await sidepanel.waitForLoadState('networkidle'); + // Wait for the UI to fully initialize + await sidepanel.waitForTimeout(2000); + } + + // Check if we're authenticated by looking for the chat input + const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { + timeout: 10000, + }).catch(() => null); + + if (!textarea) { + throw new Error('Could not find chat input. The extension may not be authenticated.'); + } + + // Clear any existing text and type the new message + await textarea.click(); + await textarea.fill(''); + await textarea.fill(message); + + // Get the current number of message elements before sending + const messagesBefore = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + const countBefore = messagesBefore.length; + + // Press Enter to send + await sidepanel.keyboard.press('Enter'); + + // Wait for the response to appear and complete + // We detect completion by waiting for the streaming to stop + const startTime = Date.now(); + let lastContent = ''; + let stableCount = 0; + const STABLE_THRESHOLD = 3; // Number of checks with same content to consider complete + const CHECK_INTERVAL = 500; // Check every 500ms + + while (Date.now() - startTime < timeoutMs) { + await sidepanel.waitForTimeout(CHECK_INTERVAL); + + // Find the latest assistant message + const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + + if (messages.length > countBefore) { + // Get the last message (the response) + const lastMessage = messages[messages.length - 1]; + const content = await lastMessage.textContent(); + + // Check if content has stabilized (streaming complete) + if (content === lastContent && content.length > 0) { + stableCount++; + if (stableCount >= STABLE_THRESHOLD) { + // Response is complete + return { + response: content.trim(), + }; + } + } else { + stableCount = 0; + lastContent = content; + } + } + } + + // Timeout - return whatever we have + if (lastContent) { + return { + response: lastContent.trim(), + warning: 'Response may be incomplete (timeout)', + }; + } + + throw new Error('Timeout waiting for response'); +} + +module.exports = run; diff --git a/internal/claude/scripts/stream_chat.js b/internal/claude/scripts/stream_chat.js new file mode 100644 index 0000000..b596fcd --- /dev/null +++ b/internal/claude/scripts/stream_chat.js @@ -0,0 +1,123 @@ +// Interactive streaming chat with Claude. +// This script is executed via Kernel's Playwright API. +// +// Communication protocol: +// - Reads JSON commands from stdin: { "type": "message", "content": "..." } +// - Writes JSON events to stdout: +// - { "type": "ready" } - Chat is ready for input +// - { "type": "chunk", "content": "..." } - Streaming response chunk +// - { "type": "complete", "content": "..." } - Full response complete +// - { "type": "error", "message": "..." } - Error occurred + +const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; +const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; + +function emit(event) { + console.log(JSON.stringify(event)); +} + +async function run({ context }) { + // Open the sidepanel + let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); + if (!sidepanel) { + sidepanel = await context.newPage(); + await sidepanel.goto(SIDEPANEL_URL); + await sidepanel.waitForLoadState('networkidle'); + await sidepanel.waitForTimeout(2000); + } + + // Check if authenticated + const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { + timeout: 10000, + }).catch(() => null); + + if (!textarea) { + emit({ type: 'error', message: 'Claude extension not authenticated' }); + return; + } + + emit({ type: 'ready' }); + + // Set up stdin listener for messages + const readline = require('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, + }); + + rl.on('line', async (line) => { + try { + const command = JSON.parse(line); + + if (command.type === 'message' && command.content) { + await sendMessage(sidepanel, command.content); + } else if (command.type === 'quit') { + rl.close(); + process.exit(0); + } + } catch (e) { + emit({ type: 'error', message: e.message }); + } + }); + + // Keep the script running + await new Promise(() => {}); +} + +async function sendMessage(page, message) { + const textarea = await page.$('textarea, [contenteditable="true"]'); + if (!textarea) { + emit({ type: 'error', message: 'Chat input not found' }); + return; + } + + // Get current message count + const messagesBefore = await page.$$('[data-testid="message"], .message, [class*="Message"]'); + const countBefore = messagesBefore.length; + + // Send the message + await textarea.click(); + await textarea.fill(''); + await textarea.fill(message); + await page.keyboard.press('Enter'); + + // Stream the response + let lastContent = ''; + let stableCount = 0; + const STABLE_THRESHOLD = 5; + const CHECK_INTERVAL = 200; + const TIMEOUT = 300000; // 5 minutes + const startTime = Date.now(); + + while (Date.now() - startTime < TIMEOUT) { + await page.waitForTimeout(CHECK_INTERVAL); + + const messages = await page.$$('[data-testid="message"], .message, [class*="Message"]'); + + if (messages.length > countBefore) { + const lastMessage = messages[messages.length - 1]; + const content = await lastMessage.textContent(); + + // Emit chunk if content changed + if (content !== lastContent) { + const newContent = content.slice(lastContent.length); + if (newContent) { + emit({ type: 'chunk', content: newContent }); + } + lastContent = content; + stableCount = 0; + } else if (content.length > 0) { + stableCount++; + if (stableCount >= STABLE_THRESHOLD) { + emit({ type: 'complete', content: content.trim() }); + return; + } + } + } + } + + emit({ type: 'error', message: 'Response timeout' }); +} + +module.exports = run; From fb48d814ccf4e5d30aef65a59d9aa761532851e3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 15:25:07 -0500 Subject: [PATCH 02/17] fix: use valid extension name 'claude-for-chrome' --- internal/claude/loader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index a56226b..49a385a 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -84,7 +84,7 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { if err := opts.Client.Browsers.LoadExtensions(ctx, opts.BrowserID, kernel.BrowserLoadExtensionsParams{ Extensions: []kernel.BrowserLoadExtensionsParamsExtension{ { - Name: ExtensionName, + Name: "claude-for-chrome", ZipFile: extZipFile, }, }, From e5de29c581bbd16b9a460d49958c2dced4474a7c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 15:31:37 -0500 Subject: [PATCH 03/17] fix: wait for browser to be ready before loading extension Add retry loop to poll GET /browsers/:id after creation to handle eventual consistency before attempting to load the extension. --- cmd/claude/launch.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cmd/claude/launch.go b/cmd/claude/launch.go index 3c33555..95e9921 100644 --- a/cmd/claude/launch.go +++ b/cmd/claude/launch.go @@ -3,6 +3,7 @@ package claude import ( "context" "fmt" + "time" "github.com/onkernel/cli/internal/claude" "github.com/onkernel/cli/pkg/util" @@ -110,6 +111,12 @@ func runLaunch(cmd *cobra.Command, args []string) error { pterm.Info.Printf("Created browser: %s\n", browser.SessionID) + // Wait for browser to be ready (eventual consistency) + if err := waitForBrowserReady(ctx, client, browser.SessionID); err != nil { + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + return fmt.Errorf("browser not ready: %w", err) + } + // Load the Claude extension pterm.Info.Println("Loading Claude extension...") if err := claude.LoadIntoBrowser(ctx, claude.LoadIntoBrowserOptions{ @@ -190,3 +197,23 @@ func parseViewport(viewport string) (int64, int64, int64, error) { return 0, 0, 0, fmt.Errorf("invalid format, expected WIDTHxHEIGHT[@RATE]") } + +// waitForBrowserReady polls until the browser is accessible via GET. +// This handles eventual consistency after browser creation. +func waitForBrowserReady(ctx context.Context, client kernel.Client, browserID string) error { + const maxAttempts = 10 + const delay = 500 * time.Millisecond + + for attempt := 1; attempt <= maxAttempts; attempt++ { + _, err := client.Browsers.Get(ctx, browserID) + if err == nil { + return nil + } + + if attempt < maxAttempts { + time.Sleep(delay) + } + } + + return fmt.Errorf("browser %s not accessible after %d attempts", browserID, maxAttempts) +} From a8593d6edd1b7bedb6f98b61484c08b008e9460a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 15:37:21 -0500 Subject: [PATCH 04/17] fix: use chown -R to recursively set auth storage ownership The LevelDB files inside the auth storage directory need to be owned by the kernel user, not just the directory itself. Use process exec with chown -R to fix the LOCK file access denied errors. --- internal/claude/loader.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index 49a385a..72476ac 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -55,14 +55,17 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { return fmt.Errorf("failed to upload auth storage: %w", err) } - // Set proper ownership on the auth storage directory - if err := fs.SetFilePermissions(ctx, opts.BrowserID, kernel.BrowserFSetFilePermissionsParams{ - Path: authDestPath, - Mode: "0755", - Owner: kernel.Opt(KernelUser), - Group: kernel.Opt(KernelUser), - }); err != nil { - return fmt.Errorf("failed to set auth storage permissions: %w", err) + // Set proper ownership on the auth storage directory and all files inside + // using chown -R via process exec (SetFilePermissions is not recursive) + proc := opts.Client.Browsers.Process + _, err = proc.Exec(ctx, opts.BrowserID, kernel.BrowserProcessExecParams{ + Command: "chown", + Args: []string{"-R", KernelUser + ":" + KernelUser, authDestPath}, + AsRoot: kernel.Opt(true), + TimeoutSec: kernel.Opt(int64(30)), + }) + if err != nil { + return fmt.Errorf("failed to set auth storage ownership: %w", err) } } From 3dc820fbe478e5d2decfbd0b648fd1851f0e72c2 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 15:40:26 -0500 Subject: [PATCH 05/17] fix: truncate CDP WebSocket URL in launch output table --- cmd/claude/launch.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/claude/launch.go b/cmd/claude/launch.go index 95e9921..4295eb9 100644 --- a/cmd/claude/launch.go +++ b/cmd/claude/launch.go @@ -148,7 +148,7 @@ func runLaunch(cmd *cobra.Command, args []string) error { {"Property", "Value"}, {"Browser ID", browser.SessionID}, {"Live View URL", browser.BrowserLiveViewURL}, - {"CDP WebSocket URL", browser.CdpWsURL}, + {"CDP WebSocket URL", truncateURL(browser.CdpWsURL, 60)}, {"Timeout (seconds)", fmt.Sprintf("%d", timeout)}, } if bundle.HasAuthStorage() { @@ -217,3 +217,11 @@ func waitForBrowserReady(ctx context.Context, client kernel.Client, browserID st return fmt.Errorf("browser %s not accessible after %d attempts", browserID, maxAttempts) } + +// truncateURL truncates a URL to a maximum length, adding "..." if truncated. +func truncateURL(url string, maxLen int) string { + if len(url) <= maxLen { + return url + } + return url[:maxLen-3] + "..." +} From ad7ffb3d406bc8be229b6ce3558e8069c57632f1 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 15:43:29 -0500 Subject: [PATCH 06/17] fix: don't navigate to claude.ai by default on launch --- cmd/claude/launch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/claude/launch.go b/cmd/claude/launch.go index 4295eb9..af44d4d 100644 --- a/cmd/claude/launch.go +++ b/cmd/claude/launch.go @@ -45,7 +45,7 @@ func init() { launchCmd.Flags().IntP("timeout", "t", 600, "Session timeout in seconds") launchCmd.Flags().BoolP("stealth", "s", false, "Launch browser in stealth mode") launchCmd.Flags().BoolP("headless", "H", false, "Launch browser in headless mode") - launchCmd.Flags().String("url", "https://claude.ai", "Initial URL to navigate to") + launchCmd.Flags().String("url", "", "Initial URL to navigate to (optional)") launchCmd.Flags().Bool("chat", false, "Start interactive chat after launch") launchCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25)") From 066a1ed4a913b955ba9c1e6b3923adde4df0776a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:07:16 -0500 Subject: [PATCH 07/17] feat: pin Claude extension to toolbar on launch - Add pinExtension() to update Chrome's Preferences file with the extension ID in pinned_extensions array - Restart Chromium via supervisorctl to pick up the new preference - Use Spawn (fire and forget) since supervisorctl restart takes time --- internal/claude/loader.go | 92 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index 72476ac..23182ce 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -1,8 +1,11 @@ package claude import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "os" "path/filepath" @@ -10,6 +13,9 @@ import ( "github.com/onkernel/kernel-go-sdk" ) +// KernelPreferencesPath is the path to Chrome's Preferences file in Kernel browsers +const KernelPreferencesPath = "/home/kernel/user-data/Default/Preferences" + // LoadIntoBrowserOptions configures how the extension is loaded into a browser. type LoadIntoBrowserOptions struct { // BrowserID is the Kernel browser session ID @@ -95,6 +101,92 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { return fmt.Errorf("failed to load extension: %w", err) } + // Step 3: Pin the extension to the toolbar + if err := pinExtension(ctx, opts.Client, opts.BrowserID, ExtensionID); err != nil { + // Don't fail the whole operation if pinning fails - it's a nice-to-have + // The extension is still loaded and functional + return nil + } + + // Step 4: Restart Chromium to pick up the new pinned extension preference + // Use Spawn (fire and forget) because supervisorctl restart waits a long time + proc := opts.Client.Browsers.Process + _, _ = proc.Spawn(ctx, opts.BrowserID, kernel.BrowserProcessSpawnParams{ + Command: "supervisorctl", + Args: []string{"restart", "chromium"}, + AsRoot: kernel.Opt(true), + }) + + return nil +} + +// pinExtension adds an extension ID to Chrome's pinned_extensions list in the Preferences file. +// This makes the extension icon visible in the toolbar by default. +func pinExtension(ctx context.Context, client kernel.Client, browserID, extensionID string) error { + fs := client.Browsers.Fs + + // Read the current Preferences file + resp, err := fs.ReadFile(ctx, browserID, kernel.BrowserFReadFileParams{ + Path: KernelPreferencesPath, + }) + if err != nil { + return fmt.Errorf("failed to read preferences: %w", err) + } + defer resp.Body.Close() + + prefsData, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read preferences body: %w", err) + } + + // Parse the JSON + var prefs map[string]any + if err := json.Unmarshal(prefsData, &prefs); err != nil { + return fmt.Errorf("failed to parse preferences: %w", err) + } + + // Get or create the extensions object + extensions, ok := prefs["extensions"].(map[string]any) + if !ok { + extensions = make(map[string]any) + prefs["extensions"] = extensions + } + + // Get or create the pinned_extensions array + var pinnedExtensions []string + if pinned, ok := extensions["pinned_extensions"].([]any); ok { + for _, id := range pinned { + if s, ok := id.(string); ok { + pinnedExtensions = append(pinnedExtensions, s) + } + } + } + + // Check if extension is already pinned + for _, id := range pinnedExtensions { + if id == extensionID { + // Already pinned, nothing to do + return nil + } + } + + // Add the extension to pinned list + pinnedExtensions = append(pinnedExtensions, extensionID) + extensions["pinned_extensions"] = pinnedExtensions + + // Serialize back to JSON + newPrefsData, err := json.Marshal(prefs) + if err != nil { + return fmt.Errorf("failed to serialize preferences: %w", err) + } + + // Write the updated Preferences file + if err := fs.WriteFile(ctx, browserID, bytes.NewReader(newPrefsData), kernel.BrowserFWriteFileParams{ + Path: KernelPreferencesPath, + }); err != nil { + return fmt.Errorf("failed to write preferences: %w", err) + } + return nil } From abae93f5bcc1185ae256edc25b1a9fa1cbd4f96a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:12:09 -0500 Subject: [PATCH 08/17] fix: stop Chrome before updating Preferences for extension pinning Chrome overwrites the Preferences file on exit, so we need to: 1. Stop Chromium via supervisorctl stop 2. Update the Preferences file with pinned_extensions 3. Start Chromium via supervisorctl start This ensures Chrome reads our updated Preferences on startup. --- internal/claude/loader.go | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index 23182ce..de57d64 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -102,18 +102,38 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { } // Step 3: Pin the extension to the toolbar + // We need to: + // 1. Stop Chromium so it doesn't overwrite our Preferences changes + // 2. Update the Preferences file + // 3. Restart Chromium to pick up the changes + proc := opts.Client.Browsers.Process + + // Stop Chromium first (use Exec to wait for it to complete) + _, _ = proc.Exec(ctx, opts.BrowserID, kernel.BrowserProcessExecParams{ + Command: "supervisorctl", + Args: []string{"stop", "chromium"}, + AsRoot: kernel.Opt(true), + TimeoutSec: kernel.Opt(int64(30)), + }) + + // Now update the Preferences file while Chrome is stopped if err := pinExtension(ctx, opts.Client, opts.BrowserID, ExtensionID); err != nil { // Don't fail the whole operation if pinning fails - it's a nice-to-have // The extension is still loaded and functional + // But still restart Chromium + _, _ = proc.Spawn(ctx, opts.BrowserID, kernel.BrowserProcessSpawnParams{ + Command: "supervisorctl", + Args: []string{"start", "chromium"}, + AsRoot: kernel.Opt(true), + }) return nil } - // Step 4: Restart Chromium to pick up the new pinned extension preference - // Use Spawn (fire and forget) because supervisorctl restart waits a long time - proc := opts.Client.Browsers.Process + // Restart Chromium to pick up the new pinned extension preference + // Use Spawn (fire and forget) because supervisorctl start can take time _, _ = proc.Spawn(ctx, opts.BrowserID, kernel.BrowserProcessSpawnParams{ Command: "supervisorctl", - Args: []string{"restart", "chromium"}, + Args: []string{"start", "chromium"}, AsRoot: kernel.Opt(true), }) From 259b3d8d5aafa1478a4bc1dd67c9a4c462ba04e7 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:16:07 -0500 Subject: [PATCH 09/17] feat: navigate tabs to chrome://newtab after loading Claude extension The Claude extension opens claude.ai by default, which shows a login prompt. After loading the extension and restarting Chrome, navigate all tabs to chrome://newtab for a cleaner initial state. --- internal/claude/loader.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index de57d64..628c992 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -130,11 +130,25 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { } // Restart Chromium to pick up the new pinned extension preference - // Use Spawn (fire and forget) because supervisorctl start can take time - _, _ = proc.Spawn(ctx, opts.BrowserID, kernel.BrowserProcessSpawnParams{ - Command: "supervisorctl", - Args: []string{"start", "chromium"}, - AsRoot: kernel.Opt(true), + // Use Exec to wait for it to start before navigating + _, _ = proc.Exec(ctx, opts.BrowserID, kernel.BrowserProcessExecParams{ + Command: "supervisorctl", + Args: []string{"start", "chromium"}, + AsRoot: kernel.Opt(true), + TimeoutSec: kernel.Opt(int64(30)), + }) + + // Step 4: Navigate all tabs to chrome://newtab to avoid the Claude login page + // The extension opens claude.ai by default which shows a login prompt + navigateScript := ` + const pages = context.pages(); + for (const p of pages) { + await p.goto('chrome://newtab'); + } + ` + _, _ = opts.Client.Browsers.Playwright.Execute(ctx, opts.BrowserID, kernel.BrowserPlaywrightExecuteParams{ + Code: navigateScript, + TimeoutSec: kernel.Opt(int64(30)), }) return nil From 1aa9c80264d54b87deb5a96e6207ef53e00984df Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:18:08 -0500 Subject: [PATCH 10/17] fix: close extra tabs opened by Claude extension The Claude extension opens a second tab to claude.ai. Close all but the first tab before navigating to chrome://newtab. --- internal/claude/loader.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index 628c992..8d4d38d 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -138,12 +138,17 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { TimeoutSec: kernel.Opt(int64(30)), }) - // Step 4: Navigate all tabs to chrome://newtab to avoid the Claude login page - // The extension opens claude.ai by default which shows a login prompt + // Step 4: Close extra tabs and navigate to chrome://newtab + // The Claude extension opens a tab to claude.ai by default navigateScript := ` const pages = context.pages(); - for (const p of pages) { - await p.goto('chrome://newtab'); + // Close all but the first page + for (let i = 1; i < pages.length; i++) { + await pages[i].close(); + } + // Navigate the remaining page to newtab + if (pages.length > 0) { + await pages[0].goto('chrome://newtab'); } ` _, _ = opts.Client.Browsers.Playwright.Execute(ctx, opts.BrowserID, kernel.BrowserPlaywrightExecuteParams{ From 725dc66a96f75b4479fdd1341f614fbc4dc4dee1 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:26:40 -0500 Subject: [PATCH 11/17] perf: use Spawn for supervisorctl start, rely on SDK retry The SDK's Playwright.Execute has built-in retry, so we can fire and forget the supervisorctl start command instead of waiting for it. --- internal/claude/loader.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index 8d4d38d..6cda66d 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -130,12 +130,11 @@ func LoadIntoBrowser(ctx context.Context, opts LoadIntoBrowserOptions) error { } // Restart Chromium to pick up the new pinned extension preference - // Use Exec to wait for it to start before navigating - _, _ = proc.Exec(ctx, opts.BrowserID, kernel.BrowserProcessExecParams{ - Command: "supervisorctl", - Args: []string{"start", "chromium"}, - AsRoot: kernel.Opt(true), - TimeoutSec: kernel.Opt(int64(30)), + // Use Spawn (fire and forget) - the Playwright call below will retry until Chrome is ready + _, _ = proc.Spawn(ctx, opts.BrowserID, kernel.BrowserProcessSpawnParams{ + Command: "supervisorctl", + Args: []string{"start", "chromium"}, + AsRoot: kernel.Opt(true), }) // Step 4: Close extra tabs and navigate to chrome://newtab From bd507f0137a6e8895bf77b0018299080c561fef5 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:51:40 -0500 Subject: [PATCH 12/17] fix: remove module.exports from Playwright scripts The Playwright execution API expects inline code, not CommonJS modules. Remove the async function wrapper and module.exports, making the scripts execute directly in the Playwright context. --- internal/claude/scripts/check_status.js | 92 ++++++++------- internal/claude/scripts/send_message.js | 142 ++++++++++++------------ internal/claude/scripts/stream_chat.js | 96 ++++++++-------- 3 files changed, 159 insertions(+), 171 deletions(-) diff --git a/internal/claude/scripts/check_status.js b/internal/claude/scripts/check_status.js index 775a3ce..2b2898f 100644 --- a/internal/claude/scripts/check_status.js +++ b/internal/claude/scripts/check_status.js @@ -7,62 +7,58 @@ const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; -async function run({ context }) { - const status = { - extensionLoaded: false, - authenticated: false, - error: null, - hasConversation: false, - }; +const status = { + extensionLoaded: false, + authenticated: false, + error: null, + hasConversation: false, +}; +try { + // Try to open the sidepanel + const sidepanel = await context.newPage(); + try { - // Try to open the sidepanel - const sidepanel = await context.newPage(); - - try { - await sidepanel.goto(SIDEPANEL_URL, { timeout: 10000 }); - await sidepanel.waitForLoadState('networkidle', { timeout: 10000 }); - status.extensionLoaded = true; - } catch (e) { - status.error = 'Extension not loaded or not accessible'; - return status; - } - - // Wait a bit for the UI to initialize - await sidepanel.waitForTimeout(2000); + await sidepanel.goto(SIDEPANEL_URL, { timeout: 10000 }); + await sidepanel.waitForLoadState('networkidle', { timeout: 10000 }); + status.extensionLoaded = true; + } catch (e) { + status.error = 'Extension not loaded or not accessible'; + return status; + } - // Check for authentication indicators - // Look for chat input (indicates authenticated) - const chatInput = await sidepanel.$('textarea, [contenteditable="true"]'); - if (chatInput) { - status.authenticated = true; - } + // Wait a bit for the UI to initialize + await sidepanel.waitForTimeout(2000); - // Look for login/sign-in elements (indicates not authenticated) - const loginButton = await sidepanel.$('button:has-text("Sign in"), button:has-text("Log in"), a:has-text("Sign in")'); - if (loginButton) { - status.authenticated = false; - } + // Check for authentication indicators + // Look for chat input (indicates authenticated) + const chatInput = await sidepanel.$('textarea, [contenteditable="true"]'); + if (chatInput) { + status.authenticated = true; + } - // Check for any error messages - const errorElement = await sidepanel.$('[class*="error"], [class*="Error"], [role="alert"]'); - if (errorElement) { - const errorText = await errorElement.textContent(); - status.error = errorText?.trim() || 'Unknown error'; - } + // Look for login/sign-in elements (indicates not authenticated) + const loginButton = await sidepanel.$('button:has-text("Sign in"), button:has-text("Log in"), a:has-text("Sign in")'); + if (loginButton) { + status.authenticated = false; + } - // Check if there are existing messages (conversation in progress) - const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); - status.hasConversation = messages.length > 0; + // Check for any error messages + const errorElement = await sidepanel.$('[class*="error"], [class*="Error"], [role="alert"]'); + if (errorElement) { + const errorText = await errorElement.textContent(); + status.error = errorText?.trim() || 'Unknown error'; + } - // Close the test page - await sidepanel.close(); + // Check if there are existing messages (conversation in progress) + const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + status.hasConversation = messages.length > 0; - } catch (e) { - status.error = e.message; - } + // Close the test page + await sidepanel.close(); - return status; +} catch (e) { + status.error = e.message; } -module.exports = run; +return status; diff --git a/internal/claude/scripts/send_message.js b/internal/claude/scripts/send_message.js index 1f42a54..008e055 100644 --- a/internal/claude/scripts/send_message.js +++ b/internal/claude/scripts/send_message.js @@ -1,9 +1,9 @@ // Send a message to Claude and wait for the response. // This script is executed via Kernel's Playwright API. // -// Input (via environment/args): -// - CLAUDE_MESSAGE: The message to send -// - CLAUDE_TIMEOUT_MS: Timeout in milliseconds (default: 120000) +// Input (via environment variables set before this script): +// - process.env.CLAUDE_MESSAGE: The message to send +// - process.env.CLAUDE_TIMEOUT_MS: Timeout in milliseconds (default: 120000) // // Output: // - Returns JSON with { response: string, model?: string } @@ -11,89 +11,85 @@ const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; -async function run({ context }) { - const message = process.env.CLAUDE_MESSAGE; - const timeoutMs = parseInt(process.env.CLAUDE_TIMEOUT_MS || '120000', 10); - - if (!message) { - throw new Error('CLAUDE_MESSAGE environment variable is required'); - } +const message = process.env.CLAUDE_MESSAGE; +const timeoutMs = parseInt(process.env.CLAUDE_TIMEOUT_MS || '120000', 10); - // Find or open the sidepanel page - let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); - if (!sidepanel) { - sidepanel = await context.newPage(); - await sidepanel.goto(SIDEPANEL_URL); - await sidepanel.waitForLoadState('networkidle'); - // Wait for the UI to fully initialize - await sidepanel.waitForTimeout(2000); - } +if (!message) { + throw new Error('CLAUDE_MESSAGE environment variable is required'); +} - // Check if we're authenticated by looking for the chat input - const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { - timeout: 10000, - }).catch(() => null); +// Find or open the sidepanel page +let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); +if (!sidepanel) { + sidepanel = await context.newPage(); + await sidepanel.goto(SIDEPANEL_URL); + await sidepanel.waitForLoadState('networkidle'); + // Wait for the UI to fully initialize + await sidepanel.waitForTimeout(2000); +} - if (!textarea) { - throw new Error('Could not find chat input. The extension may not be authenticated.'); - } +// Check if we're authenticated by looking for the chat input +const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { + timeout: 10000, +}).catch(() => null); - // Clear any existing text and type the new message - await textarea.click(); - await textarea.fill(''); - await textarea.fill(message); +if (!textarea) { + throw new Error('Could not find chat input. The extension may not be authenticated.'); +} + +// Clear any existing text and type the new message +await textarea.click(); +await textarea.fill(''); +await textarea.fill(message); - // Get the current number of message elements before sending - const messagesBefore = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); - const countBefore = messagesBefore.length; +// Get the current number of message elements before sending +const messagesBefore = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); +const countBefore = messagesBefore.length; - // Press Enter to send - await sidepanel.keyboard.press('Enter'); +// Press Enter to send +await sidepanel.keyboard.press('Enter'); - // Wait for the response to appear and complete - // We detect completion by waiting for the streaming to stop - const startTime = Date.now(); - let lastContent = ''; - let stableCount = 0; - const STABLE_THRESHOLD = 3; // Number of checks with same content to consider complete - const CHECK_INTERVAL = 500; // Check every 500ms +// Wait for the response to appear and complete +// We detect completion by waiting for the streaming to stop +const startTime = Date.now(); +let lastContent = ''; +let stableCount = 0; +const STABLE_THRESHOLD = 3; // Number of checks with same content to consider complete +const CHECK_INTERVAL = 500; // Check every 500ms - while (Date.now() - startTime < timeoutMs) { - await sidepanel.waitForTimeout(CHECK_INTERVAL); +while (Date.now() - startTime < timeoutMs) { + await sidepanel.waitForTimeout(CHECK_INTERVAL); - // Find the latest assistant message - const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + // Find the latest assistant message + const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + + if (messages.length > countBefore) { + // Get the last message (the response) + const lastMessage = messages[messages.length - 1]; + const content = await lastMessage.textContent(); - if (messages.length > countBefore) { - // Get the last message (the response) - const lastMessage = messages[messages.length - 1]; - const content = await lastMessage.textContent(); - - // Check if content has stabilized (streaming complete) - if (content === lastContent && content.length > 0) { - stableCount++; - if (stableCount >= STABLE_THRESHOLD) { - // Response is complete - return { - response: content.trim(), - }; - } - } else { - stableCount = 0; - lastContent = content; + // Check if content has stabilized (streaming complete) + if (content === lastContent && content.length > 0) { + stableCount++; + if (stableCount >= STABLE_THRESHOLD) { + // Response is complete + return { + response: content.trim(), + }; } + } else { + stableCount = 0; + lastContent = content; } } +} - // Timeout - return whatever we have - if (lastContent) { - return { - response: lastContent.trim(), - warning: 'Response may be incomplete (timeout)', - }; - } - - throw new Error('Timeout waiting for response'); +// Timeout - return whatever we have +if (lastContent) { + return { + response: lastContent.trim(), + warning: 'Response may be incomplete (timeout)', + }; } -module.exports = run; +throw new Error('Timeout waiting for response'); diff --git a/internal/claude/scripts/stream_chat.js b/internal/claude/scripts/stream_chat.js index b596fcd..6a65b97 100644 --- a/internal/claude/scripts/stream_chat.js +++ b/internal/claude/scripts/stream_chat.js @@ -16,55 +16,6 @@ function emit(event) { console.log(JSON.stringify(event)); } -async function run({ context }) { - // Open the sidepanel - let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); - if (!sidepanel) { - sidepanel = await context.newPage(); - await sidepanel.goto(SIDEPANEL_URL); - await sidepanel.waitForLoadState('networkidle'); - await sidepanel.waitForTimeout(2000); - } - - // Check if authenticated - const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { - timeout: 10000, - }).catch(() => null); - - if (!textarea) { - emit({ type: 'error', message: 'Claude extension not authenticated' }); - return; - } - - emit({ type: 'ready' }); - - // Set up stdin listener for messages - const readline = require('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: false, - }); - - rl.on('line', async (line) => { - try { - const command = JSON.parse(line); - - if (command.type === 'message' && command.content) { - await sendMessage(sidepanel, command.content); - } else if (command.type === 'quit') { - rl.close(); - process.exit(0); - } - } catch (e) { - emit({ type: 'error', message: e.message }); - } - }); - - // Keep the script running - await new Promise(() => {}); -} - async function sendMessage(page, message) { const textarea = await page.$('textarea, [contenteditable="true"]'); if (!textarea) { @@ -120,4 +71,49 @@ async function sendMessage(page, message) { emit({ type: 'error', message: 'Response timeout' }); } -module.exports = run; +// Open the sidepanel +let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); +if (!sidepanel) { + sidepanel = await context.newPage(); + await sidepanel.goto(SIDEPANEL_URL); + await sidepanel.waitForLoadState('networkidle'); + await sidepanel.waitForTimeout(2000); +} + +// Check if authenticated +const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { + timeout: 10000, +}).catch(() => null); + +if (!textarea) { + emit({ type: 'error', message: 'Claude extension not authenticated' }); + return { error: 'not authenticated' }; +} + +emit({ type: 'ready' }); + +// Set up stdin listener for messages +const readline = require('readline'); +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, +}); + +rl.on('line', async (line) => { + try { + const command = JSON.parse(line); + + if (command.type === 'message' && command.content) { + await sendMessage(sidepanel, command.content); + } else if (command.type === 'quit') { + rl.close(); + process.exit(0); + } + } catch (e) { + emit({ type: 'error', message: e.message }); + } +}); + +// Keep the script running +await new Promise(() => {}); From 6e4be7cbc36ceedf6e7c2af11c3d2dca528ab8de Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 16:59:59 -0500 Subject: [PATCH 13/17] fix: update Playwright scripts with correct Claude UI selectors - Use div.claude-response for Claude's response messages - Use [contenteditable].ProseMirror for the chat input - These match the actual DOM structure of the Claude extension --- internal/claude/scripts/check_status.js | 8 +++---- internal/claude/scripts/send_message.js | 32 +++++++++++++------------ internal/claude/scripts/stream_chat.js | 29 +++++++++++----------- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/internal/claude/scripts/check_status.js b/internal/claude/scripts/check_status.js index 2b2898f..dcdf7f4 100644 --- a/internal/claude/scripts/check_status.js +++ b/internal/claude/scripts/check_status.js @@ -31,8 +31,8 @@ try { await sidepanel.waitForTimeout(2000); // Check for authentication indicators - // Look for chat input (indicates authenticated) - const chatInput = await sidepanel.$('textarea, [contenteditable="true"]'); + // Look for chat input (indicates authenticated) - Claude uses ProseMirror contenteditable + const chatInput = await sidepanel.$('[contenteditable="true"].ProseMirror, textarea'); if (chatInput) { status.authenticated = true; } @@ -51,8 +51,8 @@ try { } // Check if there are existing messages (conversation in progress) - const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); - status.hasConversation = messages.length > 0; + const responses = await sidepanel.$$('div.claude-response'); + status.hasConversation = responses.length > 0; // Close the test page await sidepanel.close(); diff --git a/internal/claude/scripts/send_message.js b/internal/claude/scripts/send_message.js index 008e055..cb8a26c 100644 --- a/internal/claude/scripts/send_message.js +++ b/internal/claude/scripts/send_message.js @@ -29,22 +29,24 @@ if (!sidepanel) { } // Check if we're authenticated by looking for the chat input -const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { +// Claude uses a contenteditable div with ProseMirror +const inputSelector = '[contenteditable="true"].ProseMirror, textarea'; +const input = await sidepanel.waitForSelector(inputSelector, { timeout: 10000, }).catch(() => null); -if (!textarea) { +if (!input) { throw new Error('Could not find chat input. The extension may not be authenticated.'); } -// Clear any existing text and type the new message -await textarea.click(); -await textarea.fill(''); -await textarea.fill(message); +// Get the current number of Claude responses before sending +const responsesBefore = await sidepanel.$$('div.claude-response'); +const countBefore = responsesBefore.length; -// Get the current number of message elements before sending -const messagesBefore = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); -const countBefore = messagesBefore.length; +// Clear any existing text and type the new message +await input.click(); +await input.fill(''); +await input.fill(message); // Press Enter to send await sidepanel.keyboard.press('Enter'); @@ -60,13 +62,13 @@ const CHECK_INTERVAL = 500; // Check every 500ms while (Date.now() - startTime < timeoutMs) { await sidepanel.waitForTimeout(CHECK_INTERVAL); - // Find the latest assistant message - const messages = await sidepanel.$$('[data-testid="message"], .message, [class*="Message"]'); + // Find Claude responses + const responses = await sidepanel.$$('div.claude-response'); - if (messages.length > countBefore) { - // Get the last message (the response) - const lastMessage = messages[messages.length - 1]; - const content = await lastMessage.textContent(); + if (responses.length > countBefore) { + // Get the last response + const lastResponse = responses[responses.length - 1]; + const content = await lastResponse.textContent(); // Check if content has stabilized (streaming complete) if (content === lastContent && content.length > 0) { diff --git a/internal/claude/scripts/stream_chat.js b/internal/claude/scripts/stream_chat.js index 6a65b97..b4ceb76 100644 --- a/internal/claude/scripts/stream_chat.js +++ b/internal/claude/scripts/stream_chat.js @@ -17,20 +17,20 @@ function emit(event) { } async function sendMessage(page, message) { - const textarea = await page.$('textarea, [contenteditable="true"]'); - if (!textarea) { + const input = await page.$('[contenteditable="true"].ProseMirror, textarea'); + if (!input) { emit({ type: 'error', message: 'Chat input not found' }); return; } - // Get current message count - const messagesBefore = await page.$$('[data-testid="message"], .message, [class*="Message"]'); - const countBefore = messagesBefore.length; + // Get current response count + const responsesBefore = await page.$$('div.claude-response'); + const countBefore = responsesBefore.length; // Send the message - await textarea.click(); - await textarea.fill(''); - await textarea.fill(message); + await input.click(); + await input.fill(''); + await input.fill(message); await page.keyboard.press('Enter'); // Stream the response @@ -44,11 +44,11 @@ async function sendMessage(page, message) { while (Date.now() - startTime < TIMEOUT) { await page.waitForTimeout(CHECK_INTERVAL); - const messages = await page.$$('[data-testid="message"], .message, [class*="Message"]'); + const responses = await page.$$('div.claude-response'); - if (messages.length > countBefore) { - const lastMessage = messages[messages.length - 1]; - const content = await lastMessage.textContent(); + if (responses.length > countBefore) { + const lastResponse = responses[responses.length - 1]; + const content = await lastResponse.textContent(); // Emit chunk if content changed if (content !== lastContent) { @@ -81,11 +81,12 @@ if (!sidepanel) { } // Check if authenticated -const textarea = await sidepanel.waitForSelector('textarea, [contenteditable="true"]', { +const inputSelector = '[contenteditable="true"].ProseMirror, textarea'; +const input = await sidepanel.waitForSelector(inputSelector, { timeout: 10000, }).catch(() => null); -if (!textarea) { +if (!input) { emit({ type: 'error', message: 'Claude extension not authenticated' }); return { error: 'not authenticated' }; } From 4bad0564a418f71cad23f625940679b583c67242 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 17:03:18 -0500 Subject: [PATCH 14/17] fix: press Enter twice for slash commands Slash commands in Claude require an extra Enter press to confirm. Detect if message starts with '/' and press Enter a second time. --- internal/claude/scripts/send_message.js | 6 ++++++ internal/claude/scripts/stream_chat.js | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/internal/claude/scripts/send_message.js b/internal/claude/scripts/send_message.js index cb8a26c..75f97f2 100644 --- a/internal/claude/scripts/send_message.js +++ b/internal/claude/scripts/send_message.js @@ -51,6 +51,12 @@ await input.fill(message); // Press Enter to send await sidepanel.keyboard.press('Enter'); +// Slash commands need an extra Enter to confirm +if (message.startsWith('/')) { + await sidepanel.waitForTimeout(500); + await sidepanel.keyboard.press('Enter'); +} + // Wait for the response to appear and complete // We detect completion by waiting for the streaming to stop const startTime = Date.now(); diff --git a/internal/claude/scripts/stream_chat.js b/internal/claude/scripts/stream_chat.js index b4ceb76..8d356be 100644 --- a/internal/claude/scripts/stream_chat.js +++ b/internal/claude/scripts/stream_chat.js @@ -33,6 +33,12 @@ async function sendMessage(page, message) { await input.fill(message); await page.keyboard.press('Enter'); + // Slash commands need an extra Enter to confirm + if (message.startsWith('/')) { + await page.waitForTimeout(500); + await page.keyboard.press('Enter'); + } + // Stream the response let lastContent = ''; let stableCount = 0; From db85bb709119cfd54837459a33b219254b70e9dc Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 17:17:24 -0500 Subject: [PATCH 15/17] fix: open Claude side panel by clicking extension icon Instead of navigating to the sidepanel URL (which opens it as a broken full tab), click on the pinned extension icon at coordinates (1775, 55) to open the proper side panel. - Add OpenSidePanel() function that uses Computer.ClickMouse API - Add ExtensionIconX/Y constants for the click coordinates - Update send, status, and chat commands to call OpenSidePanel first - Update Playwright scripts to wait for the side panel to appear instead of trying to navigate to it --- cmd/claude/chat.go | 5 ++++ cmd/claude/send.go | 5 ++++ cmd/claude/status.go | 5 ++++ internal/claude/constants.go | 8 ++++++ internal/claude/loader.go | 9 +++++++ internal/claude/scripts/check_status.js | 33 ++++++++++++++----------- internal/claude/scripts/send_message.js | 24 ++++++++++++------ internal/claude/scripts/stream_chat.js | 24 ++++++++++++------ 8 files changed, 83 insertions(+), 30 deletions(-) diff --git a/cmd/claude/chat.go b/cmd/claude/chat.go index c3d9f84..1489ec6 100644 --- a/cmd/claude/chat.go +++ b/cmd/claude/chat.go @@ -61,6 +61,11 @@ func runChatWithBrowser(ctx context.Context, client kernel.Client, browserID str return fmt.Errorf("failed to get browser: %w", err) } + // Open the side panel by clicking the extension icon + if err := claude.OpenSidePanel(ctx, client, browser.SessionID); err != nil { + return fmt.Errorf("failed to open side panel: %w", err) + } + // Check Claude status first pterm.Info.Println("Checking Claude extension status...") statusResult, err := client.Browsers.Playwright.Execute(ctx, browser.SessionID, kernel.BrowserPlaywrightExecuteParams{ diff --git a/cmd/claude/send.go b/cmd/claude/send.go index 73e28ad..c767136 100644 --- a/cmd/claude/send.go +++ b/cmd/claude/send.go @@ -107,6 +107,11 @@ func runSend(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get browser: %w", err) } + // Open the side panel by clicking the extension icon + if err := claude.OpenSidePanel(ctx, client, browser.SessionID); err != nil { + return fmt.Errorf("failed to open side panel: %w", err) + } + // Build the script with environment variables script := fmt.Sprintf(` process.env.CLAUDE_MESSAGE = %s; diff --git a/cmd/claude/status.go b/cmd/claude/status.go index d8f141a..7e5aef9 100644 --- a/cmd/claude/status.go +++ b/cmd/claude/status.go @@ -55,6 +55,11 @@ func runStatus(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to get browser: %w", err) } + // Open the side panel by clicking the extension icon + if err := claude.OpenSidePanel(ctx, client, browser.SessionID); err != nil { + return fmt.Errorf("failed to open side panel: %w", err) + } + // Execute the status check script if outputFormat != "json" { pterm.Info.Println("Checking Claude extension status...") diff --git a/internal/claude/constants.go b/internal/claude/constants.go index 55a3dbb..e626bd4 100644 --- a/internal/claude/constants.go +++ b/internal/claude/constants.go @@ -32,4 +32,12 @@ const ( // BundleAuthStorageDir is the directory name for auth storage within the bundle BundleAuthStorageDir = "auth-storage" + + // ExtensionIconX is the X coordinate for clicking the pinned Claude extension icon + // to open the side panel (for 1920x1080 screen resolution) + ExtensionIconX = 1775 + + // ExtensionIconY is the Y coordinate for clicking the pinned Claude extension icon + // to open the side panel (for 1920x1080 screen resolution) + ExtensionIconY = 55 ) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index 6cda66d..b6608da 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -228,6 +228,15 @@ func pinExtension(ctx context.Context, client kernel.Client, browserID, extensio return nil } +// OpenSidePanel clicks on the pinned Claude extension icon to open the side panel. +// This uses the computer API to click at the known coordinates of the extension icon. +func OpenSidePanel(ctx context.Context, client kernel.Client, browserID string) error { + return client.Browsers.Computer.ClickMouse(ctx, browserID, kernel.BrowserComputerClickMouseParams{ + X: ExtensionIconX, + Y: ExtensionIconY, + }) +} + // createTempZip creates a temporary zip file from a directory. func createTempZip(srcDir string) (string, error) { tmpFile, err := os.CreateTemp("", "claude-*.zip") diff --git a/internal/claude/scripts/check_status.js b/internal/claude/scripts/check_status.js index dcdf7f4..b1ed2f6 100644 --- a/internal/claude/scripts/check_status.js +++ b/internal/claude/scripts/check_status.js @@ -1,11 +1,11 @@ // Check the status of the Claude extension. // This script is executed via Kernel's Playwright API. +// The side panel should already be open (via OpenSidePanel click). // // Output: // - Returns JSON with extension status information const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; -const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; const status = { extensionLoaded: false, @@ -15,20 +15,26 @@ const status = { }; try { - // Try to open the sidepanel - const sidepanel = await context.newPage(); - - try { - await sidepanel.goto(SIDEPANEL_URL, { timeout: 10000 }); - await sidepanel.waitForLoadState('networkidle', { timeout: 10000 }); - status.extensionLoaded = true; - } catch (e) { - status.error = 'Extension not loaded or not accessible'; + // Wait for the side panel to appear (it was opened by clicking the extension icon) + let sidepanel = null; + const maxWaitMs = 10000; + const startWait = Date.now(); + while (Date.now() - startWait < maxWaitMs) { + sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); + if (sidepanel) break; + await new Promise(r => setTimeout(r, 500)); + } + + if (!sidepanel) { + status.error = 'Side panel not found. Extension may not be loaded or pinned.'; return status; } - // Wait a bit for the UI to initialize - await sidepanel.waitForTimeout(2000); + status.extensionLoaded = true; + + // Wait for the UI to initialize + await sidepanel.waitForLoadState('networkidle'); + await sidepanel.waitForTimeout(1000); // Check for authentication indicators // Look for chat input (indicates authenticated) - Claude uses ProseMirror contenteditable @@ -54,9 +60,6 @@ try { const responses = await sidepanel.$$('div.claude-response'); status.hasConversation = responses.length > 0; - // Close the test page - await sidepanel.close(); - } catch (e) { status.error = e.message; } diff --git a/internal/claude/scripts/send_message.js b/internal/claude/scripts/send_message.js index 75f97f2..f600ec7 100644 --- a/internal/claude/scripts/send_message.js +++ b/internal/claude/scripts/send_message.js @@ -1,5 +1,6 @@ // Send a message to Claude and wait for the response. // This script is executed via Kernel's Playwright API. +// The side panel should already be open (via OpenSidePanel click). // // Input (via environment variables set before this script): // - process.env.CLAUDE_MESSAGE: The message to send @@ -9,7 +10,6 @@ // - Returns JSON with { response: string, model?: string } const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; -const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; const message = process.env.CLAUDE_MESSAGE; const timeoutMs = parseInt(process.env.CLAUDE_TIMEOUT_MS || '120000', 10); @@ -18,16 +18,24 @@ if (!message) { throw new Error('CLAUDE_MESSAGE environment variable is required'); } -// Find or open the sidepanel page -let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); +// Wait for the side panel to appear (it was opened by clicking the extension icon) +let sidepanel = null; +const maxWaitMs = 10000; +const startWait = Date.now(); +while (Date.now() - startWait < maxWaitMs) { + sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); + if (sidepanel) break; + await new Promise(r => setTimeout(r, 500)); +} + if (!sidepanel) { - sidepanel = await context.newPage(); - await sidepanel.goto(SIDEPANEL_URL); - await sidepanel.waitForLoadState('networkidle'); - // Wait for the UI to fully initialize - await sidepanel.waitForTimeout(2000); + throw new Error('Side panel not found. Make sure the extension is loaded and pinned.'); } +// Wait for the UI to fully initialize +await sidepanel.waitForLoadState('networkidle'); +await sidepanel.waitForTimeout(1000); + // Check if we're authenticated by looking for the chat input // Claude uses a contenteditable div with ProseMirror const inputSelector = '[contenteditable="true"].ProseMirror, textarea'; diff --git a/internal/claude/scripts/stream_chat.js b/internal/claude/scripts/stream_chat.js index 8d356be..95f6e15 100644 --- a/internal/claude/scripts/stream_chat.js +++ b/internal/claude/scripts/stream_chat.js @@ -1,5 +1,6 @@ // Interactive streaming chat with Claude. // This script is executed via Kernel's Playwright API. +// The side panel should already be open (via OpenSidePanel click). // // Communication protocol: // - Reads JSON commands from stdin: { "type": "message", "content": "..." } @@ -10,7 +11,6 @@ // - { "type": "error", "message": "..." } - Error occurred const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; -const SIDEPANEL_URL = `chrome-extension://${EXTENSION_ID}/sidepanel.html?mode=window`; function emit(event) { console.log(JSON.stringify(event)); @@ -77,15 +77,25 @@ async function sendMessage(page, message) { emit({ type: 'error', message: 'Response timeout' }); } -// Open the sidepanel -let sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); +// Wait for the side panel to appear (it was opened by clicking the extension icon) +let sidepanel = null; +const maxWaitMs = 10000; +const startWait = Date.now(); +while (Date.now() - startWait < maxWaitMs) { + sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); + if (sidepanel) break; + await new Promise(r => setTimeout(r, 500)); +} + if (!sidepanel) { - sidepanel = await context.newPage(); - await sidepanel.goto(SIDEPANEL_URL); - await sidepanel.waitForLoadState('networkidle'); - await sidepanel.waitForTimeout(2000); + emit({ type: 'error', message: 'Side panel not found. Extension may not be loaded or pinned.' }); + return { error: 'side panel not found' }; } +// Wait for the UI to initialize +await sidepanel.waitForLoadState('networkidle'); +await sidepanel.waitForTimeout(1000); + // Check if authenticated const inputSelector = '[contenteditable="true"].ProseMirror, textarea'; const input = await sidepanel.waitForSelector(inputSelector, { From c971821b21abcf92b32a09a3f70546f6f3976f8c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 18:01:51 -0500 Subject: [PATCH 16/17] fix: only click extension icon if side panel is not already open Check if a page with sidepanel.html exists before clicking. This prevents accidentally clicking the extensions button when the side panel is already visible. --- internal/claude/loader.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/claude/loader.go b/internal/claude/loader.go index b6608da..d204ac3 100644 --- a/internal/claude/loader.go +++ b/internal/claude/loader.go @@ -230,7 +230,27 @@ func pinExtension(ctx context.Context, client kernel.Client, browserID, extensio // OpenSidePanel clicks on the pinned Claude extension icon to open the side panel. // This uses the computer API to click at the known coordinates of the extension icon. +// If the side panel is already open, it does nothing. func OpenSidePanel(ctx context.Context, client kernel.Client, browserID string) error { + // First check if the side panel is already open + checkScript := ` + const sidepanel = context.pages().find(p => p.url().includes('sidepanel.html')); + return { isOpen: !!sidepanel }; + ` + result, err := client.Browsers.Playwright.Execute(ctx, browserID, kernel.BrowserPlaywrightExecuteParams{ + Code: checkScript, + TimeoutSec: kernel.Opt(int64(10)), + }) + if err == nil && result.Success { + if resultMap, ok := result.Result.(map[string]any); ok { + if isOpen, ok := resultMap["isOpen"].(bool); ok && isOpen { + // Side panel is already open, no need to click + return nil + } + } + } + + // Side panel is not open, click to open it return client.Browsers.Computer.ClickMouse(ctx, browserID, kernel.BrowserComputerClickMouseParams{ X: ExtensionIconX, Y: ExtensionIconY, From ef8188832b2c4dee2db3b8f91a015f6d6b295f25 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Thu, 1 Jan 2026 18:09:31 -0500 Subject: [PATCH 17/17] fix: pass unrecognized slash commands through to Claude Claude for Chrome has its own slash commands (like /hn-summary). Only handle /quit, /exit, /clear locally; pass everything else to Claude. --- cmd/claude/chat.go | 52 +++++++--------------------------------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/cmd/claude/chat.go b/cmd/claude/chat.go index 1489ec6..27b680a 100644 --- a/cmd/claude/chat.go +++ b/cmd/claude/chat.go @@ -23,11 +23,11 @@ var chatCmd = &cobra.Command{ This provides a simple command-line interface for having a conversation with Claude. Type your messages and receive responses directly in the terminal. -Special commands: +CLI commands: /quit, /exit - Exit the chat session /clear - Clear the terminal - /status - Check extension status - /help - Show available commands`, + +All other slash commands (like /hn-summary) are passed to Claude.`, Example: ` # Start chat with existing browser kernel claude chat abc123xyz @@ -104,7 +104,7 @@ func runChatWithBrowser(ctx context.Context, client kernel.Client, browserID str pterm.Info.Printf("Browser: %s\n", browserID) pterm.Info.Printf("Live View: %s\n", browser.BrowserLiveViewURL) pterm.Println() - pterm.Info.Println("Type your message and press Enter. Use /help for commands, /quit to exit.") + pterm.Info.Println("Type your message and press Enter. Use /quit to exit, /clear to clear screen.") pterm.Println() // Start the chat loop @@ -177,48 +177,10 @@ func handleChatCommand(ctx context.Context, client kernel.Client, browserID, inp pterm.Info.Println("Terminal cleared.") return true, false - case "/status": - pterm.Info.Println("Checking status...") - result, err := client.Browsers.Playwright.Execute(ctx, browserID, kernel.BrowserPlaywrightExecuteParams{ - Code: claude.CheckStatusScript, - TimeoutSec: kernel.Opt(int64(30)), - }) - if err != nil { - pterm.Error.Printf("Status check failed: %v\n", err) - return true, false - } - - var status struct { - ExtensionLoaded bool `json:"extensionLoaded"` - Authenticated bool `json:"authenticated"` - HasConversation bool `json:"hasConversation"` - Error string `json:"error"` - } - if result.Result != nil { - resultBytes, _ := json.Marshal(result.Result) - _ = json.Unmarshal(resultBytes, &status) - } - - pterm.Info.Printf("Extension: %v, Auth: %v, Conversation: %v\n", - status.ExtensionLoaded, status.Authenticated, status.HasConversation) - if status.Error != "" { - pterm.Warning.Printf("Error: %s\n", status.Error) - } - return true, false - - case "/help", "/?": - pterm.Println() - pterm.Info.Println("Available commands:") - pterm.Println(" /quit, /exit - Exit the chat session") - pterm.Println(" /clear - Clear the terminal") - pterm.Println(" /status - Check extension status") - pterm.Println(" /help - Show this help message") - pterm.Println() - return true, false - default: - pterm.Warning.Printf("Unknown command: %s (use /help for available commands)\n", cmd) - return true, false + // Pass all other slash commands through to Claude + // (Claude for Chrome has its own slash commands like /hn-summary) + return false, false } }