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..27b680a --- /dev/null +++ b/cmd/claude/chat.go @@ -0,0 +1,226 @@ +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. + +CLI commands: + /quit, /exit - Exit the chat session + /clear - Clear the terminal + +All other slash commands (like /hn-summary) are passed to Claude.`, + 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) + } + + // 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{ + 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 /quit to exit, /clear to clear screen.") + 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 + + default: + // Pass all other slash commands through to Claude + // (Claude for Chrome has its own slash commands like /hn-summary) + return false, 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..af44d4d --- /dev/null +++ b/cmd/claude/launch.go @@ -0,0 +1,227 @@ +package claude + +import ( + "context" + "fmt" + "time" + + "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", "", "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)") + + _ = 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) + + // 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{ + 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", truncateURL(browser.CdpWsURL, 60)}, + {"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]") +} + +// 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) +} + +// 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] + "..." +} 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..c767136 --- /dev/null +++ b/cmd/claude/send.go @@ -0,0 +1,190 @@ +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) + } + + // 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; +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..7e5aef9 --- /dev/null +++ b/cmd/claude/status.go @@ -0,0 +1,159 @@ +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) + } + + // 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...") + } + + 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..e626bd4 --- /dev/null +++ b/internal/claude/constants.go @@ -0,0 +1,43 @@ +// 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" + + // 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 new file mode 100644 index 0000000..d204ac3 --- /dev/null +++ b/internal/claude/loader.go @@ -0,0 +1,275 @@ +package claude + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/onkernel/cli/pkg/util" + "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 + 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 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) + } + } + + // 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: "claude-for-chrome", + ZipFile: extZipFile, + }, + }, + }); err != nil { + return fmt.Errorf("failed to load extension: %w", err) + } + + // 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 + } + + // Restart Chromium to pick up the new pinned extension preference + // 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 + // The Claude extension opens a tab to claude.ai by default + navigateScript := ` + const pages = context.pages(); + // 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{ + Code: navigateScript, + TimeoutSec: kernel.Opt(int64(30)), + }) + + 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 +} + +// 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, + }) +} + +// 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..b1ed2f6 --- /dev/null +++ b/internal/claude/scripts/check_status.js @@ -0,0 +1,67 @@ +// 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 status = { + extensionLoaded: false, + authenticated: false, + error: null, + hasConversation: false, +}; + +try { + // 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; + } + + 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 + const chatInput = await sidepanel.$('[contenteditable="true"].ProseMirror, textarea'); + 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 responses = await sidepanel.$$('div.claude-response'); + status.hasConversation = responses.length > 0; + +} catch (e) { + status.error = e.message; +} + +return status; diff --git a/internal/claude/scripts/send_message.js b/internal/claude/scripts/send_message.js new file mode 100644 index 0000000..f600ec7 --- /dev/null +++ b/internal/claude/scripts/send_message.js @@ -0,0 +1,111 @@ +// 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 +// - process.env.CLAUDE_TIMEOUT_MS: Timeout in milliseconds (default: 120000) +// +// Output: +// - Returns JSON with { response: string, model?: string } + +const EXTENSION_ID = 'fcoeoabgfenejglbffodgkkbkcdhcgfn'; + +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'); +} + +// 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) { + 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'; +const input = await sidepanel.waitForSelector(inputSelector, { + timeout: 10000, +}).catch(() => null); + +if (!input) { + throw new Error('Could not find chat input. The extension may not be authenticated.'); +} + +// Get the current number of Claude responses before sending +const responsesBefore = await sidepanel.$$('div.claude-response'); +const countBefore = responsesBefore.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'); + +// 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(); +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 Claude responses + const responses = await sidepanel.$$('div.claude-response'); + + 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) { + 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'); diff --git a/internal/claude/scripts/stream_chat.js b/internal/claude/scripts/stream_chat.js new file mode 100644 index 0000000..95f6e15 --- /dev/null +++ b/internal/claude/scripts/stream_chat.js @@ -0,0 +1,136 @@ +// 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": "..." } +// - 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'; + +function emit(event) { + console.log(JSON.stringify(event)); +} + +async function sendMessage(page, message) { + const input = await page.$('[contenteditable="true"].ProseMirror, textarea'); + if (!input) { + emit({ type: 'error', message: 'Chat input not found' }); + return; + } + + // Get current response count + const responsesBefore = await page.$$('div.claude-response'); + const countBefore = responsesBefore.length; + + // Send the message + await input.click(); + await input.fill(''); + 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; + 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 responses = await page.$$('div.claude-response'); + + if (responses.length > countBefore) { + const lastResponse = responses[responses.length - 1]; + const content = await lastResponse.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' }); +} + +// 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) { + 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, { + timeout: 10000, +}).catch(() => null); + +if (!input) { + 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(() => {});