Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions cmd/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/bgdnvk/clanker/internal/aws"
"github.com/bgdnvk/clanker/internal/azure"
"github.com/bgdnvk/clanker/internal/backend"
"github.com/bgdnvk/clanker/internal/claudecode"
"github.com/bgdnvk/clanker/internal/cloudflare"
cfanalytics "github.com/bgdnvk/clanker/internal/cloudflare/analytics"
cfdns "github.com/bgdnvk/clanker/internal/cloudflare/dns"
Expand Down Expand Up @@ -126,10 +127,12 @@ Examples:
agentName, _ := cmd.Flags().GetString("agent")
if agentName == "hermes" {
return handleHermesQuery(context.Background(), question, profile, debug)
} else if agentName == "claude-code" {
return handleClaudeCodeQuery(context.Background(), question, profile, debug)
} else if isGitHubCodingAgent(agentName) {
selectedGitHubCodingAgent = agentName
} else if agentName != "" {
return fmt.Errorf("unknown agent: %s (available: hermes, copilot, codex, claude)", agentName)
return fmt.Errorf("unknown agent: %s (available: hermes, claude-code, copilot, codex, claude)", agentName)
}

// Handle apply mode (independent of maker mode)
Expand Down Expand Up @@ -1188,7 +1191,7 @@ func init() {
askCmd.Flags().Bool("apply", false, "Apply an approved maker plan (reads from stdin unless --plan-file is provided)")
askCmd.Flags().String("plan-file", "", "Optional path to maker plan JSON file for --apply")
askCmd.Flags().Bool("route-only", false, "Return routing decision as JSON without executing (for backend integration)")
askCmd.Flags().String("agent", "", "Use a specific agent to handle the query (e.g., hermes, copilot, codex, claude)")
askCmd.Flags().String("agent", "", "Use a specific agent to handle the query (e.g., hermes, claude-code, copilot, codex, claude)")
askCmd.Flags().String("github-coding-agent-model", "", "Override the Copilot CLI model used for GitHub coding-agent delegation")
}

Expand Down Expand Up @@ -2774,6 +2777,84 @@ func handleHermesQuery(ctx context.Context, question string, profile string, deb
return nil
}

// handleClaudeCodeQuery delegates a question to the locally installed Claude Code CLI.
// When an AWS profile is available, it gathers infrastructure context first.
func handleClaudeCodeQuery(ctx context.Context, question string, profile string, debug bool) error {
version, err := claudecode.CheckAvailable()
if err != nil {
return err
}
if debug {
fmt.Fprintf(os.Stderr, "[claude-code] version: %s\n", version)
}

runner := claudecode.NewRunner(debug)

// Gather AWS infrastructure context if a profile is available.
prompt := question
targetProfile := profile
if targetProfile == "" {
defaultEnv := viper.GetString("infra.default_environment")
if defaultEnv == "" {
defaultEnv = "dev"
}
targetProfile = viper.GetString(fmt.Sprintf("infra.aws.environments.%s.profile", defaultEnv))
if targetProfile == "" {
targetProfile = viper.GetString("aws.default_profile")
}
}

if targetProfile != "" {
if debug {
fmt.Fprintf(os.Stderr, "[claude-code] gathering AWS context with profile %s\n", targetProfile)
}
awsClient, err := aws.NewClientWithProfileAndDebug(ctx, targetProfile, debug)
if err == nil {
awsContext, err := awsClient.GetRelevantContext(ctx, question)
if err == nil && strings.TrimSpace(awsContext) != "" {
prompt = fmt.Sprintf("Here is the current AWS infrastructure context:\n\n%s\n\nUser question: %s", awsContext, question)
} else if debug && err != nil {
fmt.Fprintf(os.Stderr, "[claude-code] warning: failed to get AWS context: %v\n", err)
}
} else if debug {
fmt.Fprintf(os.Stderr, "[claude-code] warning: failed to create AWS client: %v\n", err)
}
}

events, err := runner.Ask(ctx, prompt)
if err != nil {
return fmt.Errorf("claude-code agent error: %w", err)
}

hadDelta := false
for event := range events {
switch {
case event.Error != nil:
return fmt.Errorf("claude-code agent error: %w", event.Error)
case event.Text != "":
fmt.Print(event.Text)
hadDelta = true
case event.ToolCall != nil:
if debug {
fmt.Fprintf(os.Stderr, "\n[tool: %s]\n", event.ToolCall.Name)
}
case event.Thought != "":
if debug {
fmt.Fprintf(os.Stderr, "\n[thinking: %s]\n", event.Thought)
}
case event.Final != nil:
if !hadDelta && event.Final.Text != "" {
fmt.Print(event.Final.Text)
}
if debug {
fmt.Fprintf(os.Stderr, "\n[duration: %dms, cost: $%.4f]\n", event.Final.DurationMS, event.Final.CostUSD)
}
}
}
fmt.Println()
return nil
}

// buildHermesEnv resolves clanker's API keys, AI provider, and hermes config
// into environment variables for the bridge subprocess.
func buildHermesEnv() []string {
Expand Down
111 changes: 108 additions & 3 deletions cmd/talk.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"syscall"

"github.com/bgdnvk/clanker/internal/clankercloud"
"github.com/bgdnvk/clanker/internal/claudecode"
"github.com/bgdnvk/clanker/internal/hermes"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -25,7 +26,8 @@ naturally. Type 'exit' or 'quit' to end the session, or press Ctrl+D.

Examples:
clanker talk
clanker talk --agent hermes`,
clanker talk --agent hermes
clanker talk --agent claude-code`,
RunE: func(cmd *cobra.Command, args []string) error {
agentName, _ := cmd.Flags().GetString("agent")
debug := viper.GetBool("debug")
Expand All @@ -37,8 +39,10 @@ Examples:
switch agentName {
case "hermes":
return runHermesTalk(cmd.Context(), debug)
case "claude-code":
return runClaudeCodeTalk(cmd.Context(), debug)
default:
return fmt.Errorf("unknown agent: %s (available: hermes)", agentName)
return fmt.Errorf("unknown agent: %s (available: hermes, claude-code)", agentName)
}
},
}
Expand Down Expand Up @@ -167,7 +171,108 @@ func handleClankerCloudTalk(ctx context.Context, question string, debug bool) (b
return true, nil
}

func runClaudeCodeTalk(parentCtx context.Context, debug bool) error {
version, err := claudecode.CheckAvailable()
if err != nil {
return err
}

if debug {
fmt.Fprintf(os.Stderr, "[claude-code] version: %s\n", version)
}

runner := claudecode.NewRunner(debug)

ctx, cancel := context.WithCancel(parentCtx)
defer cancel()

if err := runner.StartTalk(ctx); err != nil {
return fmt.Errorf("failed to start claude-code agent: %w", err)
}
defer runner.Stop()

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
for range sigCh {
fmt.Fprintln(os.Stderr, "\nInterrupted. Type 'exit' to quit.")
}
}()
defer signal.Stop(sigCh)

fmt.Println("Claude Code Agent (interactive mode)")
fmt.Println("Type 'exit' or 'quit' to end the session.")
fmt.Println()

scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("you> ")
if !scanner.Scan() {
break
}

input := strings.TrimSpace(scanner.Text())
if input == "" {
continue
}

lower := strings.ToLower(input)
if lower == "exit" || lower == "quit" || lower == "/quit" || lower == "/exit" {
fmt.Println("Goodbye.")
break
}

routedAgent, _ := determineRoutingDecision(input)
if routedAgent == "clanker-cloud" {
if handled, err := handleClankerCloudTalk(ctx, input, debug); handled {
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
}
fmt.Println()
continue
}
}

events, err := runner.Prompt(ctx, input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
continue
}

fmt.Print("claude-code> ")
hadDelta := false
for event := range events {
switch {
case event.Error != nil:
fmt.Fprintf(os.Stderr, "\nError: %v\n", event.Error)
case event.Text != "":
fmt.Print(event.Text)
hadDelta = true
case event.ToolCall != nil:
if debug {
fmt.Fprintf(os.Stderr, "\n[tool: %s]\n", event.ToolCall.Name)
}
case event.Thought != "":
if debug {
fmt.Fprintf(os.Stderr, "\n[thinking: %s]\n", event.Thought)
}
case event.Final != nil:
if !hadDelta && event.Final.Text != "" {
fmt.Print(event.Final.Text)
}
if debug {
fmt.Fprintf(os.Stderr, "\n[duration: %dms, cost: $%.4f]\n", event.Final.DurationMS, event.Final.CostUSD)
}
}
}
fmt.Println()
fmt.Println()
}

return nil
}

func init() {
rootCmd.AddCommand(talkCmd)
talkCmd.Flags().String("agent", "", "Agent to use for conversation (default: hermes)")
talkCmd.Flags().String("agent", "", "Agent to use for conversation (default: hermes, options: hermes, claude-code)")
}
78 changes: 78 additions & 0 deletions internal/claudecode/protocol.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package claudecode

import "encoding/json"

// StreamEvent represents a single line of output from claude --output-format stream-json.
type StreamEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`

// Present when Type == "system" && Subtype == "init"
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
Tools []string `json:"tools,omitempty"`

// Present when Type == "assistant"
Message *AssistantMessage `json:"message,omitempty"`

// Present when Type == "result"
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
TotalCost float64 `json:"total_cost_usd,omitempty"`
}

// AssistantMessage is the message object inside an assistant event.
type AssistantMessage struct {
ID string `json:"id,omitempty"`
Role string `json:"role,omitempty"`
Content []ContentBlock `json:"content,omitempty"`
Usage *json.RawMessage `json:"usage,omitempty"`
}

// ContentBlock represents a single block of content in a message.
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
}

// ResultEvent is the final event from a claude --output-format json run.
type ResultEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
IsError bool `json:"is_error"`
Result string `json:"result"`
StopReason string `json:"stop_reason,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
TotalCost float64 `json:"total_cost_usd,omitempty"`
SessionID string `json:"session_id,omitempty"`
}

// Event is the normalized event type consumed by callers, matching the
// pattern established by the hermes package.
type Event struct {
Type string
Text string
ToolCall *ToolCallInfo
Thought string
Final *FinalResult
Error error
}

// ToolCallInfo holds details about a tool invocation by the agent.
type ToolCallInfo struct {
Name string
Input string
}

// FinalResult holds the completed response from the agent.
type FinalResult struct {
Text string
SessionID string
DurationMS int
CostUSD float64
}
Loading
Loading