Skip to content

Commit d9cf3fd

Browse files
committed
feat: add interactive onboard wizard for guided setup
1 parent ae9a46a commit d9cf3fd

File tree

2 files changed

+325
-1
lines changed

2 files changed

+325
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ curl -fsSL https://deploy.k8sclaw.ai/install.sh | sh
2727

2828
```bash
2929
k8sclaw install # CRDs, controllers, webhook, NATS, RBAC, network policies
30+
k8sclaw onboard # interactive setup wizard — instance, provider, channel
3031
k8sclaw uninstall # clean removal
3132
```
3233

cmd/k8sclaw/main.go

Lines changed: 324 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package main
33

44
import (
5+
"bufio"
56
"context"
67
"encoding/json"
78
"fmt"
@@ -43,7 +44,7 @@ SkillPacks, and feature gates in your Kubernetes cluster.`,
4344
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
4445
// Skip K8s client init for commands that don't need it.
4546
switch cmd.Name() {
46-
case "version", "install", "uninstall":
47+
case "version", "install", "uninstall", "onboard":
4748
return nil
4849
}
4950
return initClient()
@@ -56,6 +57,7 @@ SkillPacks, and feature gates in your Kubernetes cluster.`,
5657
rootCmd.AddCommand(
5758
newInstallCmd(),
5859
newUninstallCmd(),
60+
newOnboardCmd(),
5961
newInstancesCmd(),
6062
newRunsCmd(),
6163
newPoliciesCmd(),
@@ -404,6 +406,327 @@ const (
404406
manifestAsset = "k8sclaw-manifests.tar.gz"
405407
)
406408

409+
// ── Onboard ──────────────────────────────────────────────────────────────────
410+
411+
func newOnboardCmd() *cobra.Command {
412+
return &cobra.Command{
413+
Use: "onboard",
414+
Short: "Interactive setup wizard for K8sClaw",
415+
Long: `Walks you through creating your first ClawInstance, connecting a
416+
channel (Telegram, Slack, Discord, or WhatsApp), setting up your AI provider
417+
credentials, and optionally applying a default ClawPolicy.`,
418+
RunE: func(cmd *cobra.Command, args []string) error {
419+
return runOnboard()
420+
},
421+
}
422+
}
423+
424+
func runOnboard() error {
425+
reader := bufio.NewReader(os.Stdin)
426+
427+
printBanner()
428+
429+
// ── Step 1: Check K8sClaw is installed ───────────────────────────────
430+
fmt.Println("\n📋 Step 1/5 — Checking cluster...")
431+
if err := initClient(); err != nil {
432+
fmt.Println("\n ❌ Cannot connect to your cluster.")
433+
fmt.Println(" Make sure kubectl is configured and run: k8sclaw install")
434+
return err
435+
}
436+
437+
// Quick health check: can we list CRDs?
438+
ctx := context.Background()
439+
var instances k8sclawv1alpha1.ClawInstanceList
440+
if err := k8sClient.List(ctx, &instances, client.InNamespace(namespace)); err != nil {
441+
fmt.Println("\n ❌ K8sClaw CRDs not found. Run 'k8sclaw install' first.")
442+
return fmt.Errorf("CRDs not installed: %w", err)
443+
}
444+
fmt.Println(" ✅ K8sClaw is installed and CRDs are available.")
445+
446+
// ── Step 2: Instance name ────────────────────────────────────────────
447+
fmt.Println("\n📋 Step 2/5 — Create your ClawInstance")
448+
fmt.Println(" An instance represents you (or a tenant) in the system.")
449+
instanceName := prompt(reader, " Instance name", "my-agent")
450+
451+
// ── Step 3: AI provider ──────────────────────────────────────────────
452+
fmt.Println("\n📋 Step 3/5 — AI Provider")
453+
fmt.Println(" Which model provider do you want to use?")
454+
fmt.Println(" 1) OpenAI")
455+
fmt.Println(" 2) Anthropic")
456+
fmt.Println(" 3) Other / bring-your-own")
457+
providerChoice := prompt(reader, " Choice [1-3]", "1")
458+
459+
var providerName, secretEnvKey, modelName string
460+
switch providerChoice {
461+
case "2":
462+
providerName = "anthropic"
463+
secretEnvKey = "ANTHROPIC_API_KEY"
464+
modelName = prompt(reader, " Model name", "claude-sonnet-4-20250514")
465+
case "3":
466+
providerName = prompt(reader, " Provider name", "custom")
467+
secretEnvKey = prompt(reader, " API key env var name", "API_KEY")
468+
modelName = prompt(reader, " Model name", "")
469+
default:
470+
providerName = "openai"
471+
secretEnvKey = "OPENAI_API_KEY"
472+
modelName = prompt(reader, " Model name", "gpt-4o")
473+
}
474+
475+
apiKey := promptSecret(reader, fmt.Sprintf(" %s", secretEnvKey))
476+
if apiKey == "" {
477+
fmt.Println(" ⚠ No API key provided — you can add it later:")
478+
fmt.Printf(" kubectl create secret generic %s-%s-key --from-literal=%s=<key>\n",
479+
instanceName, providerName, secretEnvKey)
480+
}
481+
482+
providerSecretName := fmt.Sprintf("%s-%s-key", instanceName, providerName)
483+
484+
// ── Step 4: Channel ──────────────────────────────────────────────────
485+
fmt.Println("\n📋 Step 4/5 — Connect a Channel (optional)")
486+
fmt.Println(" Channels let your agent receive messages from external platforms.")
487+
fmt.Println(" 1) Telegram — easiest, just talk to @BotFather")
488+
fmt.Println(" 2) Slack")
489+
fmt.Println(" 3) Discord")
490+
fmt.Println(" 4) WhatsApp")
491+
fmt.Println(" 5) Skip — I'll add a channel later")
492+
channelChoice := prompt(reader, " Choice [1-5]", "5")
493+
494+
var channelType, channelTokenKey, channelToken string
495+
switch channelChoice {
496+
case "1":
497+
channelType = "telegram"
498+
channelTokenKey = "TELEGRAM_BOT_TOKEN"
499+
fmt.Println("\n 💡 Get a bot token from https://t.me/BotFather")
500+
channelToken = promptSecret(reader, " Bot Token")
501+
case "2":
502+
channelType = "slack"
503+
channelTokenKey = "SLACK_BOT_TOKEN"
504+
fmt.Println("\n 💡 Create a Slack app at https://api.slack.com/apps")
505+
channelToken = promptSecret(reader, " Bot OAuth Token")
506+
case "3":
507+
channelType = "discord"
508+
channelTokenKey = "DISCORD_BOT_TOKEN"
509+
fmt.Println("\n 💡 Create a Discord app at https://discord.com/developers/applications")
510+
channelToken = promptSecret(reader, " Bot Token")
511+
case "4":
512+
channelType = "whatsapp"
513+
channelTokenKey = "WHATSAPP_ACCESS_TOKEN"
514+
fmt.Println("\n 💡 Set up the WhatsApp Cloud API at https://developers.facebook.com")
515+
channelToken = promptSecret(reader, " Access Token")
516+
default:
517+
channelType = ""
518+
}
519+
520+
channelSecretName := fmt.Sprintf("%s-%s-secret", instanceName, channelType)
521+
522+
// ── Step 5: Apply default policy? ────────────────────────────────────
523+
fmt.Println("\n📋 Step 5/5 — Default Policy")
524+
fmt.Println(" A ClawPolicy controls what tools agents can use, sandboxing, etc.")
525+
applyPolicy := promptYN(reader, " Apply the default policy?", true)
526+
527+
// ── Summary ──────────────────────────────────────────────────────────
528+
fmt.Println("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
529+
fmt.Println(" Summary")
530+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
531+
fmt.Printf(" Instance: %s (namespace: %s)\n", instanceName, namespace)
532+
fmt.Printf(" Provider: %s (model: %s)\n", providerName, modelName)
533+
if channelType != "" {
534+
fmt.Printf(" Channel: %s\n", channelType)
535+
} else {
536+
fmt.Println(" Channel: (none)")
537+
}
538+
fmt.Printf(" Policy: %v\n", applyPolicy)
539+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
540+
541+
if !promptYN(reader, "\n Proceed?", true) {
542+
fmt.Println(" Aborted.")
543+
return nil
544+
}
545+
546+
// ── Apply resources ──────────────────────────────────────────────────
547+
fmt.Println()
548+
549+
// 1. Create AI provider secret.
550+
if apiKey != "" {
551+
fmt.Printf(" Creating secret %s...\n", providerSecretName)
552+
// Delete first to allow re-runs.
553+
_ = kubectl("delete", "secret", providerSecretName, "-n", namespace, "--ignore-not-found")
554+
if err := kubectl("create", "secret", "generic", providerSecretName,
555+
"-n", namespace,
556+
fmt.Sprintf("--from-literal=%s=%s", secretEnvKey, apiKey)); err != nil {
557+
return fmt.Errorf("create provider secret: %w", err)
558+
}
559+
}
560+
561+
// 2. Create channel secret.
562+
if channelType != "" && channelToken != "" {
563+
fmt.Printf(" Creating secret %s...\n", channelSecretName)
564+
_ = kubectl("delete", "secret", channelSecretName, "-n", namespace, "--ignore-not-found")
565+
if err := kubectl("create", "secret", "generic", channelSecretName,
566+
"-n", namespace,
567+
fmt.Sprintf("--from-literal=%s=%s", channelTokenKey, channelToken)); err != nil {
568+
return fmt.Errorf("create channel secret: %w", err)
569+
}
570+
}
571+
572+
// 3. Apply default policy.
573+
policyName := "default-policy"
574+
if applyPolicy {
575+
fmt.Println(" Applying default ClawPolicy...")
576+
policyYAML := buildDefaultPolicyYAML(policyName, namespace)
577+
if err := kubectlApplyStdin(policyYAML); err != nil {
578+
return fmt.Errorf("apply policy: %w", err)
579+
}
580+
}
581+
582+
// 4. Create ClawInstance.
583+
fmt.Printf(" Creating ClawInstance %s...\n", instanceName)
584+
instanceYAML := buildClawInstanceYAML(instanceName, namespace, modelName,
585+
providerName, providerSecretName, channelType, channelSecretName,
586+
policyName, applyPolicy)
587+
if err := kubectlApplyStdin(instanceYAML); err != nil {
588+
return fmt.Errorf("apply instance: %w", err)
589+
}
590+
591+
// ── Done ─────────────────────────────────────────────────────────────
592+
fmt.Println("\n ✅ Onboarding complete!")
593+
fmt.Println()
594+
fmt.Println(" Next steps:")
595+
fmt.Println(" ─────────────────────────────────────────────────")
596+
fmt.Printf(" • Check your instance: k8sclaw instances get %s\n", instanceName)
597+
if channelType == "telegram" {
598+
fmt.Println(" • Send a message to your Telegram bot — it's live!")
599+
}
600+
fmt.Printf(" • Run an agent: kubectl apply -f config/samples/agentrun_sample.yaml\n")
601+
fmt.Printf(" • View runs: k8sclaw runs list\n")
602+
fmt.Printf(" • Feature gates: k8sclaw features list --policy %s\n", policyName)
603+
fmt.Println()
604+
return nil
605+
}
606+
607+
func printBanner() {
608+
fmt.Println()
609+
fmt.Println(" ╔═══════════════════════════════════════════╗")
610+
fmt.Println(" ║ K8sClaw · Onboarding Wizard ║")
611+
fmt.Println(" ╚═══════════════════════════════════════════╝")
612+
}
613+
614+
// prompt shows a prompt with an optional default and returns the user's input.
615+
func prompt(reader *bufio.Reader, label, defaultVal string) string {
616+
if defaultVal != "" {
617+
fmt.Printf("%s [%s]: ", label, defaultVal)
618+
} else {
619+
fmt.Printf("%s: ", label)
620+
}
621+
line, _ := reader.ReadString('\n')
622+
line = strings.TrimSpace(line)
623+
if line == "" {
624+
return defaultVal
625+
}
626+
return line
627+
}
628+
629+
// promptSecret reads input without showing a default.
630+
func promptSecret(reader *bufio.Reader, label string) string {
631+
fmt.Printf("%s: ", label)
632+
line, _ := reader.ReadString('\n')
633+
return strings.TrimSpace(line)
634+
}
635+
636+
// promptYN asks a yes/no question.
637+
func promptYN(reader *bufio.Reader, label string, defaultYes bool) bool {
638+
hint := "Y/n"
639+
if !defaultYes {
640+
hint = "y/N"
641+
}
642+
fmt.Printf("%s [%s]: ", label, hint)
643+
line, _ := reader.ReadString('\n')
644+
line = strings.TrimSpace(strings.ToLower(line))
645+
if line == "" {
646+
return defaultYes
647+
}
648+
return line == "y" || line == "yes"
649+
}
650+
651+
func buildDefaultPolicyYAML(name, ns string) string {
652+
return fmt.Sprintf(`apiVersion: k8sclaw.io/v1alpha1
653+
kind: ClawPolicy
654+
metadata:
655+
name: %s
656+
namespace: %s
657+
spec:
658+
toolGating:
659+
defaultAction: allow
660+
rules:
661+
- tool: exec_command
662+
action: ask
663+
- tool: write_file
664+
action: allow
665+
conditions:
666+
maxCallsPerRun: 100
667+
- tool: network_request
668+
action: deny
669+
execGating:
670+
allowShell: true
671+
allowedCommands: [git, npm, go, python, make]
672+
deniedCommands: ["rm -rf /", curl, wget]
673+
maxExecTime: "30s"
674+
subagentPolicy:
675+
maxDepth: 3
676+
maxConcurrent: 5
677+
inheritTools: true
678+
sandboxPolicy:
679+
required: false
680+
defaultImage: ghcr.io/alexsjones/k8sclaw/sandbox:latest
681+
maxCPU: "4"
682+
maxMemory: 8Gi
683+
featureGates:
684+
browser-automation: false
685+
code-execution: true
686+
file-access: true
687+
`, name, ns)
688+
}
689+
690+
func buildClawInstanceYAML(name, ns, model, provider, providerSecret,
691+
channelType, channelSecret, policyName string, hasPolicy bool) string {
692+
693+
var channelsBlock string
694+
if channelType != "" {
695+
channelsBlock = fmt.Sprintf(` channels:
696+
- type: %s
697+
configRef:
698+
secret: %s
699+
`, channelType, channelSecret)
700+
}
701+
702+
var policyBlock string
703+
if hasPolicy {
704+
policyBlock = fmt.Sprintf(" policyRef: %s\n", policyName)
705+
}
706+
707+
return fmt.Sprintf(`apiVersion: k8sclaw.io/v1alpha1
708+
kind: ClawInstance
709+
metadata:
710+
name: %s
711+
namespace: %s
712+
spec:
713+
%s agents:
714+
default:
715+
model: %s
716+
authRefs:
717+
- provider: %s
718+
secret: %s
719+
%s`, name, ns, channelsBlock, model, provider, providerSecret, policyBlock)
720+
}
721+
722+
func kubectlApplyStdin(yaml string) error {
723+
cmd := exec.Command("kubectl", "apply", "-f", "-")
724+
cmd.Stdin = strings.NewReader(yaml)
725+
cmd.Stdout = os.Stdout
726+
cmd.Stderr = os.Stderr
727+
return cmd.Run()
728+
}
729+
407730
func newInstallCmd() *cobra.Command {
408731
var manifestVersion string
409732
cmd := &cobra.Command{

0 commit comments

Comments
 (0)