Skip to content
Merged
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
98 changes: 96 additions & 2 deletions cmd/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
cfzerotrust "github.com/bgdnvk/clanker/internal/cloudflare/zerotrust"
"github.com/bgdnvk/clanker/internal/dbcontext"
"github.com/bgdnvk/clanker/internal/digitalocean"
"github.com/bgdnvk/clanker/internal/tencent"
"github.com/bgdnvk/clanker/internal/flyio"
"github.com/bgdnvk/clanker/internal/gcp"
ghclient "github.com/bgdnvk/clanker/internal/github"
Expand Down Expand Up @@ -118,6 +119,7 @@ Examples:
includeFlyio, _ := cmd.Flags().GetBool("flyio")
includeRailway, _ := cmd.Flags().GetBool("railway")
includeVerda, _ := cmd.Flags().GetBool("verda")
includeTencent, _ := cmd.Flags().GetBool("tencent")
sreMode, _ := cmd.Flags().GetBool("sre")
includeTerraform, _ := cmd.Flags().GetBool("terraform")
includeIAM, _ := cmd.Flags().GetBool("iam")
Expand Down Expand Up @@ -384,6 +386,21 @@ Examples:
})
}

if strings.EqualFold(strings.TrimSpace(makerPlan.Provider), "tencent") {
tcCreds := tencent.ResolveCredentials()
if tcCreds.SecretID == "" || tcCreds.SecretKey == "" {
return fmt.Errorf("tencent credentials are required for --apply (set tencent.secret_id / tencent.secret_key, TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY, or TENCENT_SECRET_ID / TENCENT_SECRET_KEY)")
}
return maker.ExecuteTencentPlan(ctx, makerPlan, maker.ExecOptions{
TencentSecretID: tcCreds.SecretID,
TencentSecretKey: tcCreds.SecretKey,
TencentRegion: tcCreds.Region,
Writer: os.Stdout,
Destroyer: destroyer,
Debug: debug,
})
}

// Resolve AWS profile/region for execution.
targetProfile := resolveAWSProfile(profile)

Expand Down Expand Up @@ -521,6 +538,7 @@ Examples:
explicitVercel := cmd.Flags().Changed("vercel") && includeVercel
explicitRailway := cmd.Flags().Changed("railway") && includeRailway
explicitVerda := cmd.Flags().Changed("verda") && includeVerda
explicitTencent := cmd.Flags().Changed("tencent") && includeTencent
explicitCount := 0
if explicitGCP {
explicitCount++
Expand Down Expand Up @@ -549,8 +567,11 @@ Examples:
if explicitVerda {
explicitCount++
}
if explicitTencent {
explicitCount++
}
if explicitCount > 1 {
return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda) together with --maker")
return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda, --tencent) together with --maker")
}
switch {
case explicitHetzner:
Expand Down Expand Up @@ -580,6 +601,9 @@ Examples:
case explicitVerda:
makerProvider = "verda"
makerProviderReason = "explicit"
case explicitTencent:
makerProvider = "tencent"
makerProviderReason = "explicit"
default:
svcCtx := routing.InferContext(questionForRouting(question))
if svcCtx.Cloudflare {
Expand Down Expand Up @@ -634,6 +658,8 @@ Examples:
prompt = maker.RailwayPlanPromptWithMode(question, destroyer)
case "verda":
prompt = maker.VerdaPlanPromptWithMode(question, destroyer)
case "tencent":
prompt = maker.TencentPlanPromptWithMode(question, destroyer)
default:
prompt = maker.PlanPromptWithMode(question, destroyer)
}
Expand Down Expand Up @@ -696,7 +722,7 @@ Examples:

// Handle GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, Verda, and Railway plans (output directly, no enrichment)
providerLower := strings.ToLower(strings.TrimSpace(plan.Provider))
if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" {
if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" || providerLower == "tencent" {
if plan.CreatedAt.IsZero() {
plan.CreatedAt = time.Now().UTC()
}
Expand Down Expand Up @@ -847,6 +873,11 @@ Format as a professional compliance table suitable for government security docum
return handleVerdaQuery(cmd.Context(), question, debug)
}

// Handle explicit --tencent flag
if includeTencent && !makerMode {
return handleTencentQuery(context.Background(), question, debug)
}

if !includeAWS && !includeGitHub && !includeTerraform && !includeGCP && !includeAzure && !includeCloudflare && !includeDigitalOcean && !includeHetzner && !includeVercel && !includeFlyio && !includeRailway && !includeVerda && !includeDB {
routingQuestion := questionForRouting(question)

Expand Down Expand Up @@ -1425,6 +1456,7 @@ func init() {
askCmd.Flags().Bool("flyio", false, "Include Fly.io context")
askCmd.Flags().Bool("railway", false, "Include Railway context")
askCmd.Flags().Bool("verda", false, "Include Verda Cloud (GPU/AI) infrastructure context")
askCmd.Flags().Bool("tencent", false, "Include Tencent Cloud infrastructure context")
askCmd.Flags().Bool("sre", false, "Use adaptive Clanker SRE discovery context")
askCmd.Flags().Bool("github", false, "Include GitHub repository context")
askCmd.Flags().Bool("cicd", false, "Include CI/CD context (currently GitHub Actions)")
Expand Down Expand Up @@ -2268,6 +2300,68 @@ Provide a clear, concise answer based on the data above. If the data doesn't con
return nil
}

// handleTencentQuery delegates a Tencent Cloud query to the tencent client.
// Mirrors handleDigitalOceanQuery: gather relevant context from the SDK,
// stuff it into the prompt, hand to the configured AI provider.
func handleTencentQuery(ctx context.Context, question string, debug bool) error {
if debug {
fmt.Println("Delegating query to Tencent Cloud agent...")
}

creds := tencent.ResolveCredentials()
client, err := tencent.NewClient(creds, debug)
if err != nil {
return err
}

tcContext, err := client.GetRelevantContext(ctx, question)
if err != nil {
return fmt.Errorf("failed to get Tencent Cloud context: %w", err)
}

provider := viper.GetString("ai.default_provider")
if provider == "" {
provider = "openai"
}

var apiKey string
switch provider {
case "gemini", "gemini-api":
apiKey = ""
case "openai":
apiKey = resolveOpenAIKey("")
case "anthropic":
apiKey = resolveAnthropicKey("")
case "cohere":
apiKey = resolveCohereKey("")
case "deepseek":
apiKey = resolveDeepSeekKey("")
case "minimax":
apiKey = resolveMiniMaxKey("")
default:
apiKey = viper.GetString("ai.api_key")
}

aiClient := ai.NewClient(provider, apiKey, debug, provider)

prompt := fmt.Sprintf(`You are a Tencent Cloud infrastructure expert. Answer the user's question using the inventory data below. Tencent service abbreviations: CVM=Cloud Virtual Machine, VPC=Virtual Private Cloud, SG=Security Group, COS=Cloud Object Storage, CLB=Cloud Load Balancer, TKE=Tencent Kubernetes Engine, CDB=TencentDB for MySQL.

Tencent Cloud Context:
%s

User Question: %s

Provide a clear, concise answer based on the data above. Cite specific resource IDs (ins-*, vpc-*, sg-*) when relevant. If the data is insufficient, say what is missing and suggest the specific clanker subcommand that would surface it (e.g. clanker tencent list cvm --all-regions, clanker tencent sg-rules <sg-id>).`, tcContext, question)

response, err := aiClient.AskPrompt(ctx, prompt)
if err != nil {
return fmt.Errorf("failed to get AI response: %w", err)
}

fmt.Println(response)
return nil
}

func resolveHetznerToken(ctx context.Context, debug bool) (string, error) {
apiToken := hetzner.ResolveAPIToken()
if apiToken != "" {
Expand Down
6 changes: 6 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/bgdnvk/clanker/internal/gcp"
"github.com/bgdnvk/clanker/internal/hetzner"
"github.com/bgdnvk/clanker/internal/railway"
"github.com/bgdnvk/clanker/internal/tencent"
"github.com/bgdnvk/clanker/internal/vercel"
"github.com/bgdnvk/clanker/internal/verda"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -136,6 +137,11 @@ func init() {
verdaCmd := verda.CreateVerdaCommands()
AddVerdaAskCommand(verdaCmd)
rootCmd.AddCommand(verdaCmd)

// Register Tencent Cloud static commands. Natural-language queries
// (`clanker ask --tencent ...`) will land in a later phase.
tencentCmd := tencent.CreateTencentCommands()
rootCmd.AddCommand(tencentCmd)
}

// initConfig reads in config file and ENV variables if set.
Expand Down
136 changes: 136 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package cmd

import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"

"github.com/bgdnvk/clanker/internal/api"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func init() {
var (
port int
host string
token string
insecure bool
corsOrigin string
debug bool
aiProfile string
openaiKey string
openaiModel string
anthropicKey string
geminiKey string
localModelInferenceURL string
noThinking bool
)

serverCmd := &cobra.Command{
Use: "server",
Short: "Run the Clanker HTTP API server",
Long: `Start the HTTP API server that wraps the Clanker agent.

This is the gateway for the Clanker web dashboard. Inventory + maker
apply + plan-generation endpoints all live here.

Auth: pass --token or set CLANKER_API_TOKEN. The server refuses to start
without one — POST /api/v1/maker/apply can mutate real cloud resources, so
unauthenticated startup is gated behind an explicit --insecure flag.

AI provider: pass the same --ai-profile / --openai-key / --openai-model
/ --local-model-inference-url flags you'd give to ` + "`clanker ask`" + ` so the
plan-generation endpoint can call your configured LLM. Server reads from
~/.clanker.yaml as well, so flags only override what's already there.

Examples:
# Token-gated server (recommended)
clanker server --port 8080 --token "$(openssl rand -hex 32)"

# Trusted-network mode (no auth) — NEVER on a public address
clanker server --port 8080 --host 127.0.0.1 --insecure

# With vLLM-backed plan generation
clanker server --port 8080 \
--ai-profile openai \
--openai-model qwen3.6-27b-fp8 \
--openai-key "$VLLM_API_KEY" \
--local-model-inference-url "$VLLM_BASE_URL/v1"`,
RunE: func(cmd *cobra.Command, args []string) error {
resolved := strings.TrimSpace(token)
if resolved == "" {
resolved = strings.TrimSpace(os.Getenv("CLANKER_API_TOKEN"))
}
addr := fmt.Sprintf("%s:%d", host, port)
api.SetVersion(Version)

// Push AI config into viper so api handlers building ai.NewClient
// pick the same values the CLI does. Empty flags leave existing
// config (from ~/.clanker.yaml) untouched.
if strings.TrimSpace(aiProfile) != "" {
viper.Set("ai.default_provider", aiProfile)
}
if strings.TrimSpace(openaiKey) != "" {
viper.Set("ai.providers.openai.api_key", openaiKey)
}
if strings.TrimSpace(openaiModel) != "" {
viper.Set("ai.providers.openai.model", openaiModel)
}
if strings.TrimSpace(anthropicKey) != "" {
viper.Set("ai.providers.anthropic.api_key", anthropicKey)
}
if strings.TrimSpace(geminiKey) != "" {
viper.Set("ai.providers.gemini-api.api_key", geminiKey)
}
if strings.TrimSpace(localModelInferenceURL) != "" {
viper.Set("ai.providers.openai.local_model_inference_url", strings.TrimSpace(localModelInferenceURL))
}
if noThinking {
viper.Set("ai.providers.openai.chat_template_kwargs", map[string]interface{}{"enable_thinking": false})
}

srv := api.New(api.Config{
Addr: addr,
Token: resolved,
Insecure: insecure,
CORSOrigin: corsOrigin,
Debug: debug,
}, log.New(os.Stderr, "", log.LstdFlags))

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Fprintln(os.Stderr, "[server] shutting down")
cancel()
}()
return srv.Run(ctx)
},
}

serverCmd.Flags().IntVar(&port, "port", 8080, "Port to listen on")
serverCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host to bind on (use 0.0.0.0 for all interfaces)")
serverCmd.Flags().StringVar(&token, "token", "", "Bearer token required for /api/v1/* (or set CLANKER_API_TOKEN). Required unless --insecure is passed.")
serverCmd.Flags().BoolVar(&insecure, "insecure", false, "Allow startup without a bearer token. NEVER use on a publicly reachable address — /api/v1/maker/apply mutates real cloud resources.")
serverCmd.Flags().StringVar(&corsOrigin, "cors-origin", "", "Value for Access-Control-Allow-Origin (defaults to http://localhost:4173 — pass an explicit value for non-localhost dashboards; \"*\" allowed but discouraged)")
serverCmd.Flags().BoolVar(&debug, "server-debug", false, "Log every request, not just errors")

// LLM provider flags — push into viper so the plan-generation endpoint
// has the same options the CLI exposes.
serverCmd.Flags().StringVar(&aiProfile, "ai-profile", "", "AI provider profile (openai, gemini-api, anthropic, cohere, ...)")
serverCmd.Flags().StringVar(&openaiKey, "openai-key", "", "OpenAI API key (or any OpenAI-compatible endpoint key, e.g. vLLM)")
serverCmd.Flags().StringVar(&openaiModel, "openai-model", "", "OpenAI / OpenAI-compatible model name")
serverCmd.Flags().StringVar(&anthropicKey, "anthropic-key", "", "Anthropic API key")
serverCmd.Flags().StringVar(&geminiKey, "gemini-key", "", "Gemini API key")
serverCmd.Flags().StringVar(&localModelInferenceURL, "local-model-inference-url", "", "OpenAI-compatible base URL for local/self-hosted models (e.g. https://x.runpod.net/v1)")
serverCmd.Flags().BoolVar(&noThinking, "no-thinking", false, "Disable Qwen3-style internal reasoning trace via chat_template_kwargs.enable_thinking=false. 14x faster plan generation with reasoning-capable models.")

rootCmd.AddCommand(serverCmd)
}
26 changes: 26 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ require (
github.com/mark3labs/mcp-go v0.46.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos v1.3.89
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing v1.3.84
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam v1.3.42
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs v1.3.96
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb v1.3.94
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.3.90
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.3.83
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit v1.3.40
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls v1.3.97
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.98
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.3.89
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb v1.3.98
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc v1.3.96
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lighthouse v1.3.91
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb v1.3.93
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor v1.3.96
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres v1.3.89
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis v1.3.79
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.3.94
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.3.93
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke v1.3.86
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc v1.3.83
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf v1.3.95
github.com/tencentyun/cos-go-sdk-v5 v0.7.73
golang.org/x/crypto v0.49.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
Expand Down Expand Up @@ -67,6 +91,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
github.com/aws/smithy-go v1.24.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
Expand Down Expand Up @@ -94,6 +119,7 @@ require (
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mozillazg/go-httpheader v0.2.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
Expand Down
Loading