diff --git a/cmd/dragonscale/internal/cli/commands/agent.go b/cmd/dragonscale/internal/cli/commands/agent.go new file mode 100644 index 000000000..1edabd1e8 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/agent.go @@ -0,0 +1,53 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" + "github.com/spf13/cobra" +) + +func registerAgentCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildAgentCommand(ctx) + }) +} + +func buildAgentCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Interact with the agent", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + message, err := cmd.Flags().GetString("message") + if err != nil { + return err + } + session, err := cmd.Flags().GetString("session") + if err != nil { + return err + } + debug, err := cmd.Flags().GetBool("debug") + if err != nil { + return err + } + + return ctx.Service.Agent(cmd.Context(), ctx.In, cmd.OutOrStdout(), sdk.AgentOptions{ + Message: message, + SessionKey: session, + Debug: debug, + }) + }, + } + + cmd.Flags().BoolP("debug", "d", false, "Enable debug logging") + cmd.Flags().StringP("message", "m", "", "Send a single message and exit") + cmd.Flags().StringP("session", "s", "cli:default", "Session key for conversation context") + + return cmd +} diff --git a/cmd/dragonscale/internal/cli/commands/auth.go b/cmd/dragonscale/internal/cli/commands/auth.go new file mode 100644 index 000000000..6b0820529 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/auth.go @@ -0,0 +1,99 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerAuthCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildAuthCommand(ctx) + }) +} + +func buildAuthCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Manage authentication", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(buildAuthLoginCommand(ctx)) + cmd.AddCommand(buildAuthLogoutCommand(ctx)) + cmd.AddCommand(buildAuthStatusCommand(ctx)) + + return cmd +} + +func buildAuthLoginCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate with a provider", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + provider, err := cmd.Flags().GetString("provider") + if err != nil { + return err + } + if provider == "" { + return errors.New("provider is required") + } + useDeviceCode, err := cmd.Flags().GetBool("device-code") + if err != nil { + return err + } + + return ctx.Service.AuthLogin(cmd.Context(), ctx.In, cmd.OutOrStdout(), provider, useDeviceCode) + }, + } + + cmd.Flags().StringP("provider", "p", "", "Provider to authenticate") + cmd.Flags().Bool("device-code", false, "Use device code flow") + return cmd +} + +func buildAuthLogoutCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Clear authentication credentials", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + provider, err := cmd.Flags().GetString("provider") + if err != nil { + return err + } + + return ctx.Service.AuthLogout(cmd.Context(), cmd.OutOrStdout(), provider) + }, + } + + cmd.Flags().StringP("provider", "p", "", "Provider to logout (optional)") + return cmd +} + +func buildAuthStatusCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show authentication status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.AuthStatus(cmd.Context(), cmd.OutOrStdout()) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/command_handlers_test.go b/cmd/dragonscale/internal/cli/commands/command_handlers_test.go new file mode 100644 index 000000000..8cdf8fa99 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/command_handlers_test.go @@ -0,0 +1,131 @@ +package commands + +import ( + "bytes" + "testing" +) + +func TestParseCronOptions(t *testing.T) { + t.Parallel() + + t.Run("builds options from CLI args", func(t *testing.T) { + opts, err := parseCronOptions("nightly", "250", "0 0 * * *") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Name != "nightly" { + t.Fatalf("expected name nightly, got %q", opts.Name) + } + if opts.EveryMS == nil || *opts.EveryMS != 250 { + t.Fatalf("expected everyMS=250, got %#v", opts.EveryMS) + } + if opts.Cron != "0 0 * * *" { + t.Fatalf("expected cron expr, got %q", opts.Cron) + } + }) + + t.Run("returns error for invalid every value", func(t *testing.T) { + if _, err := parseCronOptions("bad", "not-a-number", ""); err == nil { + t.Fatal("expected parse error for invalid every value") + } + }) + + t.Run("returns default options for empty inputs", func(t *testing.T) { + opts, err := parseCronOptions("", "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Name != "" || opts.Cron != "" || opts.EveryMS != nil { + t.Fatalf("expected empty defaults, got %#v", opts) + } + }) +} + +func TestCronListCommandRequiresService(t *testing.T) { + cmd := buildCronListCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil || err.Error() != "service is not initialized" { + t.Fatalf("unexpected error for cron list: %v", err) + } +} + +func TestCronAddCommandRequiresService(t *testing.T) { + cmd := buildCronAddCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceUsage = true + + // verify high-risk flag surface is wired before execution + for _, name := range []string{"name", "message", "every", "cron", "deliver", "to", "channel"} { + if cmd.Flags().Lookup(name) == nil { + t.Fatalf("missing cron flag: %s", name) + } + } + + err := cmd.Execute() + if err == nil || err.Error() != "service is not initialized" { + t.Fatalf("unexpected error for cron add: %v", err) + } +} + +func TestSkillsListCommandRequiresService(t *testing.T) { + cmd := buildSkillsListCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil || err.Error() != "service is not initialized" { + t.Fatalf("unexpected error for skills list: %v", err) + } +} + +func TestStatusCommandRequiresService(t *testing.T) { + cmd := buildStatusCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil || err.Error() != "service is not initialized" { + t.Fatalf("unexpected error for status: %v", err) + } +} + +func TestDaemonStatusCommandRequiresService(t *testing.T) { + cmd := buildDaemonStatusCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil || err.Error() != "service is not initialized" { + t.Fatalf("unexpected error for daemon status: %v", err) + } +} + +func TestGatewayCommandHasDebugFlag(t *testing.T) { + cmd := buildGatewayCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + if cmd.Flags().Lookup("debug") == nil { + t.Fatalf("missing gateway debug flag") + } +} + +func TestGatewayCommandRequiresService(t *testing.T) { + cmd := buildGatewayCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceUsage = true + + err := cmd.Execute() + if err == nil || err.Error() != "service is not initialized" { + t.Fatalf("unexpected error for gateway: %v", err) + } +} diff --git a/cmd/dragonscale/internal/cli/commands/cron.go b/cmd/dragonscale/internal/cli/commands/cron.go new file mode 100644 index 000000000..d8bbb6dc7 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/cron.go @@ -0,0 +1,175 @@ +package commands + +import ( + "errors" + "strconv" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" + "github.com/spf13/cobra" +) + +func registerCronCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildCronCommand(ctx) + }) +} + +func buildCronCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "cron", + Short: "Manage cron jobs", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(buildCronListCommand(ctx)) + cmd.AddCommand(buildCronAddCommand(ctx)) + cmd.AddCommand(buildCronRemoveCommand(ctx)) + cmd.AddCommand(buildCronEnableCommand(ctx)) + cmd.AddCommand(buildCronDisableCommand(ctx)) + + return cmd +} + +func buildCronListCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured cron jobs", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.CronList(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildCronAddCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "add", + Short: "Add a cron job", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + name, err := cmd.Flags().GetString("name") + if err != nil { + return err + } + message, err := cmd.Flags().GetString("message") + if err != nil { + return err + } + every, err := cmd.Flags().GetString("every") + if err != nil { + return err + } + cronExpr, err := cmd.Flags().GetString("cron") + if err != nil { + return err + } + deliver, err := cmd.Flags().GetBool("deliver") + if err != nil { + return err + } + to, err := cmd.Flags().GetString("to") + if err != nil { + return err + } + channel, err := cmd.Flags().GetString("channel") + if err != nil { + return err + } + + opts, err := parseCronOptions(name, every, cronExpr) + if err != nil { + return err + } + + opts.Message = message + opts.Deliver = deliver + opts.To = to + opts.Channel = channel + + return ctx.Service.CronAdd(cmd.Context(), cmd.OutOrStdout(), opts) + }, + } + + cmd.Flags().StringP("name", "n", "", "Unique job name") + cmd.Flags().StringP("message", "m", "", "Message prompt for job execution") + cmd.Flags().StringP("every", "e", "", "Run every N milliseconds") + cmd.Flags().StringP("cron", "c", "", "Cron expression schedule") + cmd.Flags().BoolP("deliver", "d", false, "Deliver result to recipient") + cmd.Flags().String("to", "", "Delivery recipient") + cmd.Flags().String("channel", "", "Delivery channel") + return cmd +} + +func parseCronOptions(name, every, cronExpr string) (sdk.CronAddOptions, error) { + opts := sdk.CronAddOptions{} + if name != "" { + opts.Name = name + } + if every != "" { + val, err := strconv.ParseInt(every, 10, 64) + if err != nil { + return opts, err + } + opts.EveryMS = &val + } + if cronExpr != "" { + opts.Cron = cronExpr + } + return opts, nil +} + +func buildCronRemoveCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "remove [job-id]", + Short: "Remove a cron job", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.CronRemove(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func buildCronEnableCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "enable [job-id]", + Short: "Enable a cron job", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.CronEnable(cmd.Context(), cmd.OutOrStdout(), args[0], true) + }, + } +} + +func buildCronDisableCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "disable [job-id]", + Short: "Disable a cron job", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.CronEnable(cmd.Context(), cmd.OutOrStdout(), args[0], false) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/daemon.go b/cmd/dragonscale/internal/cli/commands/daemon.go new file mode 100644 index 000000000..51911c83a --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/daemon.go @@ -0,0 +1,75 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerDaemonCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildDaemonCommand(ctx) + }) +} + +func buildDaemonCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "daemon", + Short: "Manage dragonscale daemon", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(buildDaemonStartCommand(ctx)) + cmd.AddCommand(buildDaemonStopCommand(ctx)) + cmd.AddCommand(buildDaemonStatusCommand(ctx)) + + return cmd +} + +func buildDaemonStartCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start daemon process", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.DaemonStart(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildDaemonStopCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop daemon process", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.DaemonStop(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildDaemonStatusCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show daemon status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.DaemonStatus(cmd.Context(), cmd.OutOrStdout()) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/gateway.go b/cmd/dragonscale/internal/cli/commands/gateway.go new file mode 100644 index 000000000..dcddd3c8a --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/gateway.go @@ -0,0 +1,40 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" + "github.com/spf13/cobra" +) + +func registerGatewayCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildGatewayCommand(ctx) + }) +} + +func buildGatewayCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "gateway", + Short: "Start dragonscale gateway", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + debug, err := cmd.Flags().GetBool("debug") + if err != nil { + return err + } + + return ctx.Service.Gateway(cmd.Context(), cmd.OutOrStdout(), sdk.GatewayOptions{ + Debug: debug, + }) + }, + } + + cmd.Flags().BoolP("debug", "d", false, "Enable debug logging") + return cmd +} diff --git a/cmd/dragonscale/internal/cli/commands/memory.go b/cmd/dragonscale/internal/cli/commands/memory.go new file mode 100644 index 000000000..3058bf5b5 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/memory.go @@ -0,0 +1,59 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerMemoryCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildMemoryCommand(ctx) + }) +} + +func buildMemoryCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "memory", + Short: "Manage memory system", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(buildMemoryMigrateSessionsCommand(ctx)) + cmd.AddCommand(buildMemoryDBStatusCommand(ctx)) + + return cmd +} + +func buildMemoryMigrateSessionsCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "migrate-sessions", + Short: "Migrate sessions into memory DB", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.MemoryMigrateSessions(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildMemoryDBStatusCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "db-status", + Short: "Show memory DB status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.MemoryDBStatus(cmd.Context(), cmd.OutOrStdout()) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/migrate.go b/cmd/dragonscale/internal/cli/commands/migrate.go new file mode 100644 index 000000000..6e0317763 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/migrate.go @@ -0,0 +1,79 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" + "github.com/spf13/cobra" +) + +func registerMigrateCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildMigrateCommand(ctx) + }) +} + +func buildMigrateCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Migrate dragonscale configuration and workspace", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + dryRun, err := cmd.Flags().GetBool("dry-run") + if err != nil { + return err + } + configOnly, err := cmd.Flags().GetBool("config-only") + if err != nil { + return err + } + workspaceOnly, err := cmd.Flags().GetBool("workspace-only") + if err != nil { + return err + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + refresh, err := cmd.Flags().GetBool("refresh") + if err != nil { + return err + } + openClawHome, err := cmd.Flags().GetString("openclaw-home") + if err != nil { + return err + } + dragonscaleHome, err := cmd.Flags().GetString("dragonscale-home") + if err != nil { + return err + } + + opts := sdk.MigrateOptions{ + DryRun: dryRun, + ConfigOnly: configOnly, + WorkspaceOnly: workspaceOnly, + Force: force, + Refresh: refresh, + OpenClawHome: openClawHome, + DragonscaleHome: dragonscaleHome, + } + + return ctx.Service.Migrate(cmd.Context(), opts, cmd.OutOrStdout()) + }, + } + + cmd.Flags().Bool("dry-run", false, "Simulate migration without writing") + cmd.Flags().Bool("config-only", false, "Migrate only config files") + cmd.Flags().Bool("workspace-only", false, "Migrate only workspace files") + cmd.Flags().Bool("force", false, "Overwrite existing files") + cmd.Flags().Bool("refresh", false, "Re-run migrations") + cmd.Flags().String("openclaw-home", "", "Path to OpenClaw home directory") + cmd.Flags().String("dragonscale-home", "", "Path to dragonscale home directory") + + return cmd +} diff --git a/cmd/dragonscale/internal/cli/commands/onboard.go b/cmd/dragonscale/internal/cli/commands/onboard.go new file mode 100644 index 000000000..789181b26 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/onboard.go @@ -0,0 +1,29 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerOnboardCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildOnboardCommand(ctx) + }) +} + +func buildOnboardCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "onboard", + Short: "Initialize dragonscale configuration and workspace", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.Onboard(cmd.Context(), ctx.In, cmd.OutOrStdout()) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/root.go b/cmd/dragonscale/internal/cli/commands/root.go new file mode 100644 index 000000000..b159d37e7 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/root.go @@ -0,0 +1,29 @@ +package commands + +import ( + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +// RegisterAll wires command factories into the global palette. +func RegisterAll() { + // Command factories are intentionally registered by their feature modules. + registerAgentCommand() + registerGatewayCommand() + registerMigrateCommand() + registerAuthCommand() + registerCronCommand() + registerSkillsCommand() + registerSecretCommand() + registerDaemonCommand() + registerMemoryCommand() + registerOnboardCommand() + registerStatusCommand() + registerVersionCommand() +} + +// BuildRoot returns the composed root command with registered command factories. +func BuildRoot(ctx *cli.AppContext) *cobra.Command { + RegisterAll() + return cli.BuildRoot(ctx) +} diff --git a/cmd/dragonscale/internal/cli/commands/root_test.go b/cmd/dragonscale/internal/cli/commands/root_test.go new file mode 100644 index 000000000..1a38c5028 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/root_test.go @@ -0,0 +1,130 @@ +package commands + +import ( + "bytes" + "strings" + "testing" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" + "github.com/spf13/cobra" +) + +func TestPaletteCommandsRejectsDuplicateCommandNames(t *testing.T) { + p := cli.Palette{ + func(*cli.AppContext) *cobra.Command { + return &cobra.Command{Use: "agent"} + }, + func(*cli.AppContext) *cobra.Command { + return &cobra.Command{Use: "agent"} + }, + func(*cli.AppContext) *cobra.Command { + return &cobra.Command{Use: "daemon"} + }, + } + + ctx := cli.NewAppContext(sdk.NewService(sdk.WithVersion("test", "", "", "")), "test", "", "", "") + defer func() { + if r := recover(); r == nil { + t.Fatal("expected duplicate command registration to panic") + } + }() + + _ = p.Commands(ctx) +} + +func TestPaletteCommandsAllowsUniqueCommandNames(t *testing.T) { + p := cli.Palette{ + func(*cli.AppContext) *cobra.Command { + return &cobra.Command{Use: "agent"} + }, + func(*cli.AppContext) *cobra.Command { + return &cobra.Command{Use: "daemon"} + }, + } + + ctx := cli.NewAppContext(sdk.NewService(sdk.WithVersion("test", "", "", "")), "test", "", "", "") + cmds := p.Commands(ctx) + if len(cmds) != 2 { + t.Fatalf("expected 2 unique commands, got %d", len(cmds)) + } +} + +func TestBuildRoot_RegistersExpectedCommands(t *testing.T) { + originalPalette := cli.DefaultPalette + cli.DefaultPalette = nil + t.Cleanup(func() { + cli.DefaultPalette = originalPalette + }) + + ctx := cli.NewAppContext(sdk.NewService(sdk.WithVersion("test", "", "", "")), "test", "", "", "") + out := &bytes.Buffer{} + ctx = ctx.WithIO(bytes.NewBuffer(nil), out, out) + + root := BuildRoot(ctx) + if got, want := root.Use, "dragonscale"; got != want { + t.Fatalf("root command use = %q, want %q", got, want) + } + + found := map[string]bool{} + for _, cmd := range root.Commands() { + found[cmd.Name()] = true + } + + expected := []string{ + "agent", + "gateway", + "migrate", + "auth", + "cron", + "skills", + "secret", + "daemon", + "memory", + "onboard", + "status", + "version", + } + + for _, name := range expected { + if !found[name] { + t.Fatalf("missing expected command: %s", name) + } + } + + if len(found) != len(expected) { + t.Fatalf("expected %d registered commands, got %d", len(expected), len(found)) + } +} + +func TestVersionCommandWritesVersionInfo(t *testing.T) { + svc := sdk.NewService(sdk.WithVersion("v9.9.9", "", "", "")) + ctx := cli.NewAppContext(svc, "v9.9.9", "", "", "") + out := &bytes.Buffer{} + ctx = ctx.WithIO(bytes.NewBuffer(nil), out, out) + + cmd := buildVersionCommand(ctx) + cmd.SetArgs([]string{}) + cmd.SetOut(out) + + if err := cmd.Execute(); err != nil { + t.Fatalf("version command returned error: %v", err) + } + + got := out.String() + if !strings.Contains(got, "dragonscale v9.9.9") { + t.Fatalf("unexpected version output: %q", got) + } +} + +func TestVersionCommandRequiresService(t *testing.T) { + cmd := buildVersionCommand(nil) + cmd.SetOut(&bytes.Buffer{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when service context is nil") + } + if err.Error() != "service is not initialized" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/dragonscale/internal/cli/commands/secret.go b/cmd/dragonscale/internal/cli/commands/secret.go new file mode 100644 index 000000000..5336a4b6d --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/secret.go @@ -0,0 +1,91 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerSecretCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildSecretCommand(ctx) + }) +} + +func buildSecretCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret", + Short: "Manage encrypted secrets", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(buildSecretInitCommand(ctx)) + cmd.AddCommand(buildSecretAddCommand(ctx)) + cmd.AddCommand(buildSecretListCommand(ctx)) + cmd.AddCommand(buildSecretDeleteCommand(ctx)) + + return cmd +} + +func buildSecretInitCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Initialize secret store", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SecretInit(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildSecretAddCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Add a secret", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SecretAdd(cmd.Context(), ctx.In, cmd.OutOrStdout(), args[0]) + }, + } +} + +func buildSecretListCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List secrets", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SecretList(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildSecretDeleteCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a secret", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SecretDelete(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/skills.go b/cmd/dragonscale/internal/cli/commands/skills.go new file mode 100644 index 000000000..e53099ae1 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/skills.go @@ -0,0 +1,140 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerSkillsCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildSkillsCommand(ctx) + }) +} + +func buildSkillsCommand(ctx *cli.AppContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Manage skills", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(buildSkillsListCommand(ctx)) + cmd.AddCommand(buildSkillsInstallCommand(ctx)) + cmd.AddCommand(buildSkillsRemoveCommand(ctx)) + cmd.AddCommand(buildSkillsInstallBuiltinCommand(ctx)) + cmd.AddCommand(buildSkillsListBuiltinCommand(ctx)) + cmd.AddCommand(buildSkillsSearchCommand(ctx)) + cmd.AddCommand(buildSkillsShowCommand(ctx)) + + return cmd +} + +func buildSkillsListCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List installed skills", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsList(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildSkillsInstallCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "install ", + Short: "Install a skill from GitHub", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsInstall(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func buildSkillsRemoveCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Aliases: []string{"uninstall"}, + Short: "Uninstall a skill", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsRemove(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} + +func buildSkillsInstallBuiltinCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "install-builtin", + Short: "Install built-in skills", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsInstallBuiltin(cmd.Context(), cmd.OutOrStdout(), "") + }, + } +} + +func buildSkillsListBuiltinCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "list-builtin", + Short: "List built-in skills", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsListBuiltin(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildSkillsSearchCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "search", + Short: "Search available skills", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsSearch(cmd.Context(), cmd.OutOrStdout()) + }, + } +} + +func buildSkillsShowCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show skill details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + + return ctx.Service.SkillsShow(cmd.Context(), cmd.OutOrStdout(), args[0]) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/status.go b/cmd/dragonscale/internal/cli/commands/status.go new file mode 100644 index 000000000..a956574ea --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/status.go @@ -0,0 +1,28 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerStatusCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildStatusCommand(ctx) + }) +} + +func buildStatusCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show dragonscale status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + return ctx.Service.Status(cmd.Context(), cmd.OutOrStdout()) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/commands/version.go b/cmd/dragonscale/internal/cli/commands/version.go new file mode 100644 index 000000000..5c2bc6a24 --- /dev/null +++ b/cmd/dragonscale/internal/cli/commands/version.go @@ -0,0 +1,28 @@ +package commands + +import ( + "errors" + + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/spf13/cobra" +) + +func registerVersionCommand() { + cli.Register(func(ctx *cli.AppContext) *cobra.Command { + return buildVersionCommand(ctx) + }) +} + +func buildVersionCommand(ctx *cli.AppContext) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show dragonscale version information", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if ctx == nil || ctx.Service == nil { + return errors.New("service is not initialized") + } + return ctx.Service.PrintVersion(cmd.Context(), cmd.OutOrStdout()) + }, + } +} diff --git a/cmd/dragonscale/internal/cli/context.go b/cmd/dragonscale/internal/cli/context.go new file mode 100644 index 000000000..60a956637 --- /dev/null +++ b/cmd/dragonscale/internal/cli/context.go @@ -0,0 +1,85 @@ +package cli + +import ( + "io" + "os" + + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" +) + +// AppContext carries shared dependencies for CLI command construction. +type AppContext struct { + Service *sdk.Service + + In io.Reader + Out io.Writer + ErrOut io.Writer + + Version string + GitCommit string + BuildTime string + GoVersion string +} + +// NewAppContext creates a CLI context with resilient defaults. +func NewAppContext(service *sdk.Service, version, gitCommit, buildTime, goVersion string) *AppContext { + if service == nil { + service = sdk.NewService(sdk.WithVersion(version, gitCommit, buildTime, goVersion)) + } else { + if version != "" { + service.Version = version + } + if gitCommit != "" { + service.GitCommit = gitCommit + } + if buildTime != "" { + service.BuildTime = buildTime + } + if goVersion != "" { + service.GoVersion = goVersion + } + } + + return &AppContext{ + Service: service, + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + Version: service.Version, + GitCommit: service.GitCommit, + BuildTime: service.BuildTime, + GoVersion: service.GoVersion, + } +} + +// WithIO returns a copy with explicit streams. +func (c *AppContext) WithIO(in io.Reader, out io.Writer, errOut io.Writer) *AppContext { + if c == nil { + return nil + } + copy := *c + if in != nil { + copy.In = in + } + if out != nil { + copy.Out = out + } + if errOut != nil { + copy.ErrOut = errOut + } + return © +} + +func (c *AppContext) stdout() io.Writer { + if c == nil || c.Out == nil { + return os.Stdout + } + return c.Out +} + +func (c *AppContext) stderr() io.Writer { + if c == nil || c.ErrOut == nil { + return os.Stderr + } + return c.ErrOut +} diff --git a/cmd/dragonscale/internal/cli/palette.go b/cmd/dragonscale/internal/cli/palette.go new file mode 100644 index 000000000..eaafe1dc1 --- /dev/null +++ b/cmd/dragonscale/internal/cli/palette.go @@ -0,0 +1,50 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// CommandFactory builds a cobra command using the provided AppContext. +type CommandFactory func(*AppContext) *cobra.Command + +// Palette is an ordered collection of CommandFactory instances that compose the command surface. +type Palette []CommandFactory + +// Register appends one or more command factories to the palette. +func (p *Palette) Register(factories ...CommandFactory) { + *p = append(*p, factories...) +} + +// Commands materializes command factories into concrete Cobra commands. +func (p Palette) Commands(ctx *AppContext) []*cobra.Command { + cmds := make([]*cobra.Command, 0, len(p)) + seen := map[string]struct{}{} + for _, f := range p { + if f == nil { + continue + } + cmd := f(ctx) + if cmd == nil { + continue + } + name := cmd.Name() + if name == "" { + continue + } + if _, exists := seen[name]; exists { + panic(fmt.Sprintf("duplicate command registration: %q", name)) + } + seen[name] = struct{}{} + cmds = append(cmds, cmd) + } + return cmds +} + +// FromCommand lifts an already-built command into a factory. +func FromCommand(c *cobra.Command) CommandFactory { + return func(_ *AppContext) *cobra.Command { + return c + } +} diff --git a/cmd/dragonscale/internal/cli/registry.go b/cmd/dragonscale/internal/cli/registry.go new file mode 100644 index 000000000..4692f6122 --- /dev/null +++ b/cmd/dragonscale/internal/cli/registry.go @@ -0,0 +1,9 @@ +package cli + +// DefaultPalette is the global command registry. +var DefaultPalette Palette + +// Register registers command factories into the global palette. +func Register(factories ...CommandFactory) { + DefaultPalette.Register(factories...) +} diff --git a/cmd/dragonscale/internal/cli/root.go b/cmd/dragonscale/internal/cli/root.go new file mode 100644 index 000000000..9ceed7479 --- /dev/null +++ b/cmd/dragonscale/internal/cli/root.go @@ -0,0 +1,60 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// NewRoot creates a root command and composes commands from the palette. +func NewRoot(ctx *AppContext, p Palette) *cobra.Command { + if ctx == nil { + ctx = NewAppContext(nil, "", "", "", "") + } + if p == nil { + p = Palette{} + } + + root := &cobra.Command{ + Use: "dragonscale", + Short: "Personal AI Assistant", + Long: "dragonscale is a CLI for interacting with your local DragonScale agent and services.", + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + root.Version = ctx.Version + root.SetContext(context.Background()) + root.AddCommand(p.Commands(ctx)...) + root.SetIn(ctx.In) + root.SetOut(ctx.stdout()) + root.SetErr(ctx.stderr()) + + return root +} + +// BuildRoot builds using the default palette. +func BuildRoot(ctx *AppContext) *cobra.Command { + return NewRoot(ctx, DefaultPalette) +} + +// Execute builds and executes the root command. +func Execute(ctx *AppContext) error { + root := BuildRoot(ctx) + if err := root.Execute(); err != nil { + return err + } + return nil +} + +// ExecuteOrExit executes the root command and exits with status 1 on error. +func ExecuteOrExit(ctx *AppContext) { + if err := Execute(ctx); err != nil { + out := ctx.stderr() + _, _ = fmt.Fprintln(out, err) + os.Exit(1) + } +} diff --git a/cmd/dragonscale/main.go b/cmd/dragonscale/main.go index d7cdbff26..707a19730 100644 --- a/cmd/dragonscale/main.go +++ b/cmd/dragonscale/main.go @@ -1,5 +1,5 @@ // DragonScale - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// Inspired by and based on picoclaw: https://github.com/sipeed/picoclaw // License: MIT // // Copyright (c) 2026 DragonScale contributors @@ -7,43 +7,12 @@ package main import ( - "bufio" - "context" "embed" - "fmt" - "io" - "io/fs" - "net/http" "os" - "os/signal" - "path/filepath" - "runtime" - "strings" - "time" - "github.com/ZanzyTHEbar/dragonscale/pkg" - "github.com/ZanzyTHEbar/dragonscale/pkg/agent" - "github.com/ZanzyTHEbar/dragonscale/pkg/auth" - "github.com/ZanzyTHEbar/dragonscale/pkg/bus" - "github.com/ZanzyTHEbar/dragonscale/pkg/channels" - "github.com/ZanzyTHEbar/dragonscale/pkg/config" - "github.com/ZanzyTHEbar/dragonscale/pkg/cron" - "github.com/ZanzyTHEbar/dragonscale/pkg/devices" - "github.com/ZanzyTHEbar/dragonscale/pkg/health" - "github.com/ZanzyTHEbar/dragonscale/pkg/heartbeat" - "github.com/ZanzyTHEbar/dragonscale/pkg/itr" - "github.com/ZanzyTHEbar/dragonscale/pkg/logger" - picomemory "github.com/ZanzyTHEbar/dragonscale/pkg/memory" - "github.com/ZanzyTHEbar/dragonscale/pkg/memory/delegate" - "github.com/ZanzyTHEbar/dragonscale/pkg/migrate" - picoruntime "github.com/ZanzyTHEbar/dragonscale/pkg/runtime" - "github.com/ZanzyTHEbar/dragonscale/pkg/security" - "github.com/ZanzyTHEbar/dragonscale/pkg/security/securebus" - "github.com/ZanzyTHEbar/dragonscale/pkg/skills" - "github.com/ZanzyTHEbar/dragonscale/pkg/state" - "github.com/ZanzyTHEbar/dragonscale/pkg/tools" - "github.com/ZanzyTHEbar/dragonscale/pkg/voice" - "github.com/chzyer/readline" + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli" + "github.com/ZanzyTHEbar/dragonscale/cmd/dragonscale/internal/cli/commands" + "github.com/ZanzyTHEbar/dragonscale/pkg/dragonscale/sdk" ) //go:generate cp -r ../../workspace . @@ -57,1987 +26,20 @@ var ( goVersion string ) -const logo = "šŸ¦ž" - -// formatVersion returns the version string with optional git commit -func formatVersion() string { - v := version - if gitCommit != "" { - v += fmt.Sprintf(" (git: %s)", gitCommit) - } - return v -} - -// formatBuildInfo returns build time and go version info -func formatBuildInfo() (build string, goVer string) { - if buildTime != "" { - build = buildTime - } - goVer = goVersion - if goVer == "" { - goVer = runtime.Version() - } - return -} - -func printVersion() { - fmt.Printf("%s dragonscale %s\n", logo, formatVersion()) - build, goVer := formatBuildInfo() - if build != "" { - fmt.Printf(" Build: %s\n", build) - } - if goVer != "" { - fmt.Printf(" Go: %s\n", goVer) - } -} - -func copyDirectory(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(src, path) - if err != nil { - return err - } - - dstPath := filepath.Join(dst, relPath) - - if info.IsDir() { - return os.MkdirAll(dstPath, info.Mode()) - } - - srcFile, err := os.Open(path) - if err != nil { - return err - } - defer srcFile.Close() - - dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) - if err != nil { - return err - } - defer dstFile.Close() - - _, err = io.Copy(dstFile, srcFile) - return err - }) -} - func main() { - if len(os.Args) < 2 { - printHelp() - os.Exit(1) - } - - command := os.Args[1] - - switch command { - case "onboard": - onboard() - case "agent": - agentCmd() - case "gateway": - gatewayCmd() - case "status": - statusCmd() - case "migrate": - migrateCmd() - case "auth": - authCmd() - case "cron": - cronCmd() - case "skills": - if len(os.Args) < 3 { - skillsHelp() - return - } - - subcommand := os.Args[2] - - skillsDir, _ := config.SkillsDir() - installer := skills.NewSkillInstaller(skillsDir) - cfgDir, _ := config.ConfigDir() - globalSkillsDir := filepath.Join(cfgDir, "skills") - builtinSkillsDir := filepath.Join(cfgDir, pkg.NAME, "skills") - skillsLoader := skills.NewSkillsLoader(skillsDir, globalSkillsDir, builtinSkillsDir) - - switch subcommand { - case "list": - skillsListCmd(skillsLoader) - case "install": - skillsInstallCmd(installer) - case "remove", "uninstall": - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale skills remove ") - return - } - skillsRemoveCmd(installer, os.Args[3]) - case "install-builtin": - skillsInstallBuiltinCmd(skillsDir) - case "list-builtin": - skillsListBuiltinCmd() - case "search": - skillsSearchCmd(installer) - case "show": - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale skills show ") - return - } - skillsShowCmd(skillsLoader, os.Args[3]) - default: - fmt.Printf("Unknown skills command: %s\n", subcommand) - skillsHelp() - } - case "secret": - secretCmd() - case "daemon": - daemonCmd() - case "memory": - memoryCmd() - case "version", "--version", "-v": - printVersion() - default: - fmt.Printf("Unknown command: %s\n", command) - printHelp() - os.Exit(1) - } -} - -func printHelp() { - fmt.Printf("%s dragonscale - Personal AI Assistant v%s\n\n", logo, version) - fmt.Println("Usage: dragonscale ") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" onboard Initialize dragonscale configuration and workspace") - fmt.Println(" agent Interact with the agent directly") - fmt.Println(" auth Manage authentication (login, logout, status)") - fmt.Println(" gateway Start dragonscale gateway") - fmt.Println(" status Show dragonscale status") - fmt.Println(" cron Manage scheduled tasks") - fmt.Println(" migrate Migrate from OpenClaw to DragonScale") - fmt.Println(" memory Memory system management (db status, session migration)") - fmt.Println(" secret Manage secrets (add, list, delete)") - fmt.Println(" daemon Manage the dragonscale daemon (start, stop, status)") - fmt.Println(" skills Manage skills (install, list, remove)") - fmt.Println(" version Show version information") -} - -func onboard() { - configPath := getConfigPath() - - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return - } - } - - cfg := config.DefaultConfig() - if err := config.SaveConfig(configPath, cfg); err != nil { - fmt.Printf("Error saving config: %v\n", err) - os.Exit(1) - } - - if migErr := migrate.MigrateToXDG(""); migErr != nil { - fmt.Printf("Warning: XDG migration failed: %v\n", migErr) - } - - createWorkspaceTemplates(cfg) - - fmt.Printf("%s dragonscale is ready!\n", logo) - - fmt.Print("\nSet up encrypted secret storage? (y/n): ") - var secretResponse string - fmt.Scanln(&secretResponse) - if secretResponse == "y" || secretResponse == "Y" { - key, err := security.GenerateKey() - if err != nil { - fmt.Printf("Error generating key: %v\n", err) - } else { - encoded := fmt.Sprintf("%x", key) - fmt.Println("\nGenerated master key (keep this safe!):") - fmt.Println(" " + encoded) - fmt.Println() - fmt.Println("Add to your shell profile:") - fmt.Println(" export DRAGONSCALE_MASTER_KEY=" + encoded) - fmt.Println() - fmt.Println("Then store secrets with: dragonscale secret add ") - } - } - - fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) - fmt.Println(" Get one at: https://openrouter.ai/keys") - fmt.Println(" 2. Chat: dragonscale agent -m \"Hello!\"") -} - -// seedEmbeddedIdentity copies identity template files from the embedded FS -// into the XDG identity directory ($XDG_CONFIG_HOME/dragonscale/identity/). -func seedEmbeddedIdentity(identityDir string) error { - identityFiles := []string{"AGENT.md", "IDENTITY.md", "SOUL.md", "USER.md"} - for _, name := range identityFiles { - data, err := embeddedFiles.ReadFile("workspace/" + name) - if err != nil { - continue - } - targetPath := filepath.Join(identityDir, name) - if _, err := os.Stat(targetPath); err == nil { - continue - } - if err := os.WriteFile(targetPath, data, 0644); err != nil { - return fmt.Errorf("write %s: %w", targetPath, err) - } - } - return nil -} - -// seedEmbeddedSkills copies skill templates from the embedded FS into the -// XDG skills directory ($XDG_DATA_HOME/dragonscale/skills/). -func seedEmbeddedSkills(skillsDir string) error { - return fs.WalkDir(embeddedFiles, "workspace/skills", func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - data, readErr := embeddedFiles.ReadFile(path) - if readErr != nil { - return readErr - } - relPath, _ := filepath.Rel("workspace/skills", path) - targetPath := filepath.Join(skillsDir, relPath) - if _, statErr := os.Stat(targetPath); statErr == nil { - return nil - } - os.MkdirAll(filepath.Dir(targetPath), 0755) - return os.WriteFile(targetPath, data, 0644) - }) -} - -func createWorkspaceTemplates(cfg *config.Config) { - identityDir, err := config.IdentityDir() - if err != nil { - fmt.Printf("Error resolving identity dir: %v\n", err) - return - } - if err := seedEmbeddedIdentity(identityDir); err != nil { - fmt.Printf("Error seeding identity files: %v\n", err) - } - - skillsDir, err := config.SkillsDir() - if err != nil { - fmt.Printf("Error resolving skills dir: %v\n", err) - return - } - if err := seedEmbeddedSkills(skillsDir); err != nil { - fmt.Printf("Error seeding skills: %v\n", err) - } - - // Ensure sandbox directory exists. - _ = os.MkdirAll(cfg.SandboxPath(), 0755) -} - -func migrateCmd() { - if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") { - migrateHelp() - return - } - - opts := migrate.Options{} - - args := os.Args[2:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--dry-run": - opts.DryRun = true - case "--config-only": - opts.ConfigOnly = true - case "--workspace-only": - opts.WorkspaceOnly = true - case "--force": - opts.Force = true - case "--refresh": - opts.Refresh = true - case "--openclaw-home": - if i+1 < len(args) { - opts.OpenClawHome = args[i+1] - i++ - } - case "--dragonscale-home": - if i+1 < len(args) { - opts.PicoClawHome = args[i+1] - i++ - } - default: - fmt.Printf("Unknown flag: %s\n", args[i]) - migrateHelp() - os.Exit(1) - } - } - - result, err := migrate.Run(opts) - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - - if !opts.DryRun { - migrate.PrintSummary(result) - } -} - -func migrateHelp() { - fmt.Println("\nMigrate from OpenClaw to DragonScale") - fmt.Println() - fmt.Println("Usage: dragonscale migrate [options]") - fmt.Println() - fmt.Println("Options:") - fmt.Println(" --dry-run Show what would be migrated without making changes") - fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)") - fmt.Println(" --config-only Only migrate config, skip workspace files") - fmt.Println(" --workspace-only Only migrate workspace files, skip config") - fmt.Println(" --force Skip confirmation prompts") - fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)") - fmt.Println(" --dragonscale-home Override DragonScale home directory (default: ~/.dragonscale)") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" dragonscale migrate Detect and migrate from OpenClaw") - fmt.Println(" dragonscale migrate --dry-run Show what would be migrated") - fmt.Println(" dragonscale migrate --refresh Re-sync workspace files") - fmt.Println(" dragonscale migrate --force Migrate without confirmation") -} - -func agentCmd() { - message := "" - sessionKey := "cli:default" - - args := os.Args[2:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--debug", "-d": - logger.SetLevel(logger.DEBUG) - fmt.Println("šŸ” Debug mode enabled") - case "-m", "--message": - if i+1 < len(args) { - message = args[i+1] - i++ - } - case "-s", "--session": - if i+1 < len(args) { - sessionKey = args[i+1] - i++ - } - } - } - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - - rt, err := bootstrapAgentRuntime(appCtx, cfg) - if err != nil { - fmt.Printf("Error initializing agent: %v\n", err) - os.Exit(1) - } - defer rt.Close() - agentLoop := rt.AgentLoop() - - closeBus := setupSecureBus(agentLoop) - defer closeBus() - - startupInfo := agentLoop.GetStartupInfo() - logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": startupInfo["tools"].(map[string]interface{})["count"], - "skills_total": startupInfo["skills"].(map[string]interface{})["total"], - "skills_available": startupInfo["skills"].(map[string]interface{})["available"], - }) - - if message != "" { - response, err := agentLoop.ProcessDirect(appCtx, message, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - fmt.Printf("\n%s %s\n", logo, response) - } else { - fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) - interactiveMode(appCtx, agentLoop, sessionKey) - } -} - -func interactiveMode(ctx context.Context, agentLoop *agent.AgentLoop, sessionKey string) { - prompt := fmt.Sprintf("%s You: ", logo) - - rl, err := readline.NewEx(&readline.Config{ - Prompt: prompt, - HistoryFile: filepath.Join(os.TempDir(), ".dragonscale_history"), - HistoryLimit: 100, - InterruptPrompt: "^C", - EOFPrompt: "exit", - }) - - if err != nil { - fmt.Printf("Error initializing readline: %v\n", err) - fmt.Println("Falling back to simple input mode...") - simpleInteractiveMode(ctx, agentLoop, sessionKey) - return - } - defer rl.Close() - - for { - line, err := rl.Readline() - if err != nil { - if err == readline.ErrInterrupt || err == io.EOF { - fmt.Println("\nGoodbye!") - return - } - fmt.Printf("Error reading input: %v\n", err) - continue - } - - input := strings.TrimSpace(line) - if input == "" { - continue - } - - if input == "exit" || input == "quit" { - fmt.Println("Goodbye!") - return - } - - response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("\n%s %s\n\n", logo, response) - } -} - -func simpleInteractiveMode(ctx context.Context, agentLoop *agent.AgentLoop, sessionKey string) { - reader := bufio.NewReader(os.Stdin) - for { - fmt.Printf("%s You: ", logo) - line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - fmt.Println("\nGoodbye!") - return - } - fmt.Printf("Error reading input: %v\n", err) - continue - } - - input := strings.TrimSpace(line) - if input == "" { - continue - } - - if input == "exit" || input == "quit" { - fmt.Println("Goodbye!") - return - } - - response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("\n%s %s\n\n", logo, response) - } -} - -func gatewayCmd() { - // Check for --debug flag - args := os.Args[2:] - for _, arg := range args { - if arg == "--debug" || arg == "-d" { - logger.SetLevel(logger.DEBUG) - fmt.Println("šŸ” Debug mode enabled") - break - } - } - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - appCtx, cancel := context.WithCancel(context.Background()) - defer cancel() - - rt, err := bootstrapAgentRuntime(appCtx, cfg) - if err != nil { - fmt.Printf("Error initializing agent: %v\n", err) - os.Exit(1) - } - defer rt.Close() - agentLoop := rt.AgentLoop() - msgBus := rt.MessageBus() - - closeBus := setupSecureBus(agentLoop) - defer closeBus() - - fmt.Println("\nšŸ“¦ Agent Status:") - startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]interface{}) - skillsInfo := startupInfo["skills"].(map[string]interface{}) - fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) - fmt.Printf(" • Skills: %d/%d available\n", - skillsInfo["available"], - skillsInfo["total"]) - - // Log to file as well - logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": toolsInfo["count"], - "skills_total": skillsInfo["total"], - "skills_available": skillsInfo["available"], - }) - - // Setup cron tool and service - execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - var cronOpts []cron.CronOption - if del := agentLoop.MemoryDelegate(); del != nil { - cronOpts = append(cronOpts, cron.WithCronDelegate(del, pkg.NAME)) - } - cronService := setupCronTool(appCtx, agentLoop, msgBus, cfg.SandboxPath(), cfg.RestrictToSandbox(), execTimeout, cronOpts...) - - var heartbeatStateOpts []state.Option - if del := agentLoop.MemoryDelegate(); del != nil { - heartbeatStateOpts = append(heartbeatStateOpts, state.WithDelegate(del)) - } - heartbeatService := heartbeat.NewHeartbeatService( - cfg.SandboxPath(), - cfg.Heartbeat.Interval, - cfg.Heartbeat.Enabled, - heartbeatStateOpts..., + ctx := cli.NewAppContext( + sdk.NewService( + sdk.WithVersion(version, gitCommit, buildTime, goVersion), + sdk.WithEmbeddedFS(embeddedFiles), + ), + version, + gitCommit, + buildTime, + goVersion, ) - heartbeatService.SetBus(msgBus) - heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - // Use cli:direct as fallback if no valid channel - if channel == "" || chatID == "" { - channel, chatID = "cli", "direct" - } - // Use ProcessHeartbeat - no session history, each heartbeat is independent - response, err := agentLoop.ProcessHeartbeat(appCtx, prompt, channel, chatID) - if err != nil { - return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) - } - if response == "HEARTBEAT_OK" { - return tools.SilentResult("Heartbeat OK") - } - // For heartbeat, always return silent - the subagent result will be - // sent to user via processSystemMessage when the async task completes - return tools.SilentResult(response) - }) - if del := agentLoop.MemoryDelegate(); del != nil { - obligations := tools.NewObligationTool(del, pkg.NAME) - heartbeatService.SetDueContextProvider(func(now time.Time) (string, error) { - ctx, cancel := context.WithTimeout(appCtx, 10*time.Second) - defer cancel() - due, err := obligations.CollectDueObligations(ctx, now, "heartbeat") - if err != nil { - return "", err - } - if len(due) == 0 { - return "", nil - } - - var b strings.Builder - b.WriteString("The following obligations are currently due and require action:\n") - for _, rec := range due { - dueAt := rec.DueAt - if dueAt.IsZero() { - dueAt = rec.ScheduledAt - } - dueLabel := "unspecified" - if !dueAt.IsZero() { - dueLabel = dueAt.Format(time.RFC3339) - } - b.WriteString(fmt.Sprintf("- [%s] %s (state=%s, due_at=%s)\n", rec.ID, rec.Title, rec.State, dueLabel)) - if strings.TrimSpace(rec.Details) != "" { - b.WriteString(fmt.Sprintf(" details: %s\n", strings.TrimSpace(rec.Details))) - } - } - b.WriteString("For each due obligation, complete the action, then call obligation update_state with executed and obligation add_evidence describing what was done.\n") - return b.String(), nil - }) - } - - channelManager, err := channels.NewManager(cfg, msgBus) - if err != nil { - fmt.Printf("Error creating channel manager: %v\n", err) + root := commands.BuildRoot(ctx) + if err := root.Execute(); err != nil { os.Exit(1) } - - // Inject channel manager into agent loop for command handling - agent.WithChannelManager(channelManager)(agentLoop) - - var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { - transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) - logger.InfoC("voice", "Groq voice transcription enabled") - } - - if transcriber != nil { - if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { - tc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Telegram channel") - } - } - if discordChannel, ok := channelManager.GetChannel("discord"); ok { - if dc, ok := discordChannel.(*channels.DiscordChannel); ok { - dc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Discord channel") - } - } - if slackChannel, ok := channelManager.GetChannel("slack"); ok { - if sc, ok := slackChannel.(*channels.SlackChannel); ok { - sc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Slack channel") - } - } - } - - enabledChannels := channelManager.GetEnabledChannels() - if len(enabledChannels) > 0 { - fmt.Printf("āœ“ Channels enabled: %s\n", enabledChannels) - } else { - fmt.Println("⚠ Warning: No channels enabled") - } - - fmt.Printf("āœ“ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) - fmt.Println("Press Ctrl+C to stop") - - if err := cronService.Start(); err != nil { - fmt.Printf("Error starting cron service: %v\n", err) - } - fmt.Println("āœ“ Cron service started") - - if err := heartbeatService.Start(); err != nil { - fmt.Printf("Error starting heartbeat service: %v\n", err) - } - fmt.Println("āœ“ Heartbeat service started") - - stateManager := state.NewManager(cfg.SandboxPath()) - deviceService := devices.NewService(devices.Config{ - Enabled: cfg.Devices.Enabled, - MonitorUSB: cfg.Devices.MonitorUSB, - }, stateManager) - deviceService.SetBus(msgBus) - if err := deviceService.Start(appCtx); err != nil { - fmt.Printf("Error starting device service: %v\n", err) - } else if cfg.Devices.Enabled { - fmt.Println("āœ“ Device event service started") - } - - if err := channelManager.StartAll(appCtx); err != nil { - fmt.Printf("Error starting channels: %v\n", err) - } - - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - go func() { - if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) - } - }() - fmt.Printf("āœ“ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) - - go agentLoop.Run(appCtx) - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) - <-sigChan - - fmt.Println("\nShutting down...") - cancel() - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - healthServer.Stop(shutdownCtx) - deviceService.Stop() - heartbeatService.Stop() - cronService.Stop() - agentLoop.Stop() - channelManager.StopAll(shutdownCtx) - fmt.Println("āœ“ Gateway stopped") -} - -func memoryCmd() { - if len(os.Args) < 3 { - memoryHelp() - return - } - - sub := os.Args[2] - switch sub { - case "migrate-sessions": - memoryMigrateSessions() - case "db-status": - memoryDBStatus() - case "--help", "-h": - memoryHelp() - default: - fmt.Printf("Unknown memory command: %s\n", sub) - memoryHelp() - } -} - -func memoryHelp() { - fmt.Println("\nMemory system management") - fmt.Println() - fmt.Println("Usage: dragonscale memory ") - fmt.Println() - fmt.Println("Subcommands:") - fmt.Println(" migrate-sessions Import file-based sessions into recall memory") - fmt.Println(" db-status Show migration version and table counts") -} - -func memoryMigrateSessions() { - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - memDBPath := cfg.DBPath() - os.MkdirAll(filepath.Dir(memDBPath), 0755) - - del, err := delegate.NewFromConfig(cfg.Memory, memDBPath) - if err != nil { - fmt.Printf("Error creating memory delegate: %v\n", err) - os.Exit(1) - } - defer del.Close() - - ctx := context.Background() - if err := del.Init(ctx); err != nil { - fmt.Printf("Error initializing memory schema: %v\n", err) - os.Exit(1) - } - - sessionsDir := filepath.Join(cfg.SandboxPath(), "sessions") - stats, err := picomemory.MigrateFileSessions(ctx, del, pkg.NAME, sessionsDir) - if err != nil { - fmt.Printf("Migration error: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Session migration complete:\n") - fmt.Printf(" Sessions found: %d\n", stats.SessionsFound) - fmt.Printf(" Sessions migrated: %d\n", stats.SessionsMigrated) - fmt.Printf(" Items created: %d\n", stats.ItemsCreated) - fmt.Printf(" Errors: %d\n", stats.Errors) -} - -func memoryDBStatus() { - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - memDBPath := cfg.DBPath() - - fi, err := os.Stat(memDBPath) - if err != nil { - fmt.Printf("Memory database not found: %s\n", memDBPath) - return - } - - fmt.Printf("Memory database: %s\n", memDBPath) - fmt.Printf("Size: %.1f KB\n", float64(fi.Size())/1024) - fmt.Printf("Embedding dims: %d\n", cfg.Memory.EmbeddingDims) - - if cfg.Memory.Sync.SyncURL != "" { - fmt.Printf("Turso replica: %s\n", cfg.Memory.Sync.SyncURL) - } else { - fmt.Println("Mode: local-only") - } -} - -func statusCmd() { - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - return - } - - configPath := getConfigPath() - - fmt.Printf("%s dragonscale Status\n", logo) - fmt.Printf("Version: %s\n", formatVersion()) - build, _ := formatBuildInfo() - if build != "" { - fmt.Printf("Build: %s\n", build) - } - fmt.Println() - - if _, err := os.Stat(configPath); err == nil { - fmt.Println("Config:", configPath, "āœ“") - } else { - fmt.Println("Config:", configPath, "āœ—") - } - - sandboxPath := cfg.SandboxPath() - if _, err := os.Stat(sandboxPath); err == nil { - fmt.Println("Sandbox:", sandboxPath, "āœ“") - } else { - fmt.Println("Sandbox:", sandboxPath, "āœ—") - } - - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model) - - hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" - hasAnthropic := cfg.Providers.Anthropic.APIKey != "" - hasOpenAI := cfg.Providers.OpenAI.APIKey != "" - hasGemini := cfg.Providers.Gemini.APIKey != "" - hasZhipu := cfg.Providers.Zhipu.APIKey != "" - hasGroq := cfg.Providers.Groq.APIKey != "" - hasVLLM := cfg.Providers.VLLM.APIBase != "" - - status := func(enabled bool) string { - if enabled { - return "āœ“" - } - return "not set" - } - fmt.Println("OpenRouter API:", status(hasOpenRouter)) - fmt.Println("Anthropic API:", status(hasAnthropic)) - fmt.Println("OpenAI API:", status(hasOpenAI)) - fmt.Println("Gemini API:", status(hasGemini)) - fmt.Println("Zhipu API:", status(hasZhipu)) - fmt.Println("Groq API:", status(hasGroq)) - if hasVLLM { - fmt.Printf("vLLM/Local: āœ“ %s\n", cfg.Providers.VLLM.APIBase) - } else { - fmt.Println("vLLM/Local: not set") - } - - store, _ := auth.LoadStore() - if store != nil && len(store.Credentials) > 0 { - fmt.Println("\nOAuth/Token Auth:") - for provider, cred := range store.Credentials { - status := "authenticated" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs refresh" - } - fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) - } - } - - // Memory system status (always enabled) - fmt.Println("\nMemory System:") - fmt.Printf(" Embedding dims: %d\n", cfg.Memory.EmbeddingDims) - if cfg.Memory.Embedding.Provider != "" { - fmt.Printf(" Embedding provider: %s\n", cfg.Memory.Embedding.Provider) - if cfg.Memory.Embedding.Model != "" { - fmt.Printf(" Embedding model: %s\n", cfg.Memory.Embedding.Model) - } - } else { - fmt.Println(" Embedding provider: none (FTS5-only)") - } - if cfg.Memory.Sync.SyncURL != "" { - fmt.Printf(" Turso replica: āœ“ %s\n", cfg.Memory.Sync.SyncURL) - fmt.Printf(" Sync interval: %ds\n", cfg.Memory.Sync.SyncIntervalSeconds) - } else { - fmt.Println(" Turso replica: local-only") - } - memDBPath := cfg.Memory.DBPath - if memDBPath == "" { - memDBPath = filepath.Join(cfg.WorkspacePath(), "memory", "dragonscale.db") - } - if fi, err := os.Stat(memDBPath); err == nil { - fmt.Printf(" DB size: %.1f KB\n", float64(fi.Size())/1024) - } - } -} - -func authCmd() { - if len(os.Args) < 3 { - authHelp() - return - } - - switch os.Args[2] { - case "login": - authLoginCmd() - case "logout": - authLogoutCmd() - case "status": - authStatusCmd() - default: - fmt.Printf("Unknown auth command: %s\n", os.Args[2]) - authHelp() - } -} - -func authHelp() { - fmt.Println("\nAuth commands:") - fmt.Println(" login Login via OAuth or paste token") - fmt.Println(" logout Remove stored credentials") - fmt.Println(" status Show current auth status") - fmt.Println() - fmt.Println("Login options:") - fmt.Println(" --provider Provider to login with (openai, anthropic)") - fmt.Println(" --device-code Use device code flow (for headless environments)") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" dragonscale auth login --provider openai") - fmt.Println(" dragonscale auth login --provider openai --device-code") - fmt.Println(" dragonscale auth login --provider anthropic") - fmt.Println(" dragonscale auth logout --provider openai") - fmt.Println(" dragonscale auth status") -} - -func authLoginCmd() { - provider := "" - useDeviceCode := false - - args := os.Args[3:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--provider", "-p": - if i+1 < len(args) { - provider = args[i+1] - i++ - } - case "--device-code": - useDeviceCode = true - } - } - - if provider == "" { - fmt.Println("Error: --provider is required") - fmt.Println("Supported providers: openai, anthropic") - return - } - - switch provider { - case "openai": - authLoginOpenAI(useDeviceCode) - case "anthropic": - authLoginPasteToken(provider) - default: - fmt.Printf("Unsupported provider: %s\n", provider) - fmt.Println("Supported providers: openai, anthropic") - } -} - -func authLoginOpenAI(useDeviceCode bool) { - cfg := auth.OpenAIOAuthConfig() - - var cred *auth.AuthCredential - var err error - - if useDeviceCode { - cred, err = auth.LoginDeviceCode(cfg) - } else { - cred, err = auth.LoginBrowser(cfg) - } - - if err != nil { - fmt.Printf("Login failed: %v\n", err) - os.Exit(1) - } - - if err := auth.SetCredential("openai", cred); err != nil { - fmt.Printf("Failed to save credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - appCfg.Providers.OpenAI.AuthMethod = "oauth" - if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { - fmt.Printf("Warning: could not update config: %v\n", err) - } - } - - fmt.Println("Login successful!") - if cred.AccountID != "" { - fmt.Printf("Account: %s\n", cred.AccountID) - } -} - -func authLoginPasteToken(provider string) { - cred, err := auth.LoginPasteToken(provider, os.Stdin) - if err != nil { - fmt.Printf("Login failed: %v\n", err) - os.Exit(1) - } - - if err := auth.SetCredential(provider, cred); err != nil { - fmt.Printf("Failed to save credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - switch provider { - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "token" - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "token" - } - if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { - fmt.Printf("Warning: could not update config: %v\n", err) - } - } - - fmt.Printf("Token saved for %s!\n", provider) -} - -func authLogoutCmd() { - provider := "" - - args := os.Args[3:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--provider", "-p": - if i+1 < len(args) { - provider = args[i+1] - i++ - } - } - } - - if provider != "" { - if err := auth.DeleteCredential(provider); err != nil { - fmt.Printf("Failed to remove credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - switch provider { - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "" - } - config.SaveConfig(getConfigPath(), appCfg) - } - - fmt.Printf("Logged out from %s\n", provider) - } else { - if err := auth.DeleteAllCredentials(); err != nil { - fmt.Printf("Failed to remove credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - appCfg.Providers.OpenAI.AuthMethod = "" - appCfg.Providers.Anthropic.AuthMethod = "" - config.SaveConfig(getConfigPath(), appCfg) - } - - fmt.Println("Logged out from all providers") - } -} - -func authStatusCmd() { - store, err := auth.LoadStore() - if err != nil { - fmt.Printf("Error loading auth store: %v\n", err) - return - } - - if len(store.Credentials) == 0 { - fmt.Println("No authenticated providers.") - fmt.Println("Run: dragonscale auth login --provider ") - return - } - - fmt.Println("\nAuthenticated Providers:") - fmt.Println("------------------------") - for provider, cred := range store.Credentials { - status := "active" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs refresh" - } - - fmt.Printf(" %s:\n", provider) - fmt.Printf(" Method: %s\n", cred.AuthMethod) - fmt.Printf(" Status: %s\n", status) - if cred.AccountID != "" { - fmt.Printf(" Account: %s\n", cred.AccountID) - } - if !cred.ExpiresAt.IsZero() { - fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) - } - } -} - -func secretCmd() { - if len(os.Args) < 3 { - secretHelp() - return - } - - sub := os.Args[2] - switch sub { - case "init": - secretInit() - case "add": - secretAdd() - case "list": - secretList() - case "delete": - secretDelete() - case "--help", "-h": - secretHelp() - default: - fmt.Printf("Unknown secret command: %s\n", sub) - secretHelp() - } -} - -func secretHelp() { - fmt.Println("\nSecret management (encrypted at rest)") - fmt.Println() - fmt.Println("Usage: dragonscale secret ") - fmt.Println() - fmt.Println("Subcommands:") - fmt.Println(" init Generate a master key (stored in keyring or env)") - fmt.Println(" add Add or update a secret") - fmt.Println(" list List stored secret names") - fmt.Println(" delete Remove a secret") - fmt.Println() - fmt.Println("Environment:") - fmt.Println(" DRAGONSCALE_MASTER_KEY 32-byte key (hex or base64) for encryption") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" dragonscale secret init") - fmt.Println(" dragonscale secret add github_token") - fmt.Println(" dragonscale secret list") - fmt.Println(" dragonscale secret delete github_token") -} - -func secretStorePath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".dragonscale", "secrets.json") -} - -func loadSecretStore() (*security.SecretStore, error) { - var keyring security.KeyringProvider - if mk := os.Getenv("DRAGONSCALE_MASTER_KEY"); mk != "" { - keyring = security.NewEnvKeyring("DRAGONSCALE_MASTER_KEY") - } else { - keyring = security.NewNoopKeyring(nil) - } - return security.NewSecretStore(secretStorePath(), keyring) -} - -func secretInit() { - key, err := security.GenerateKey() - if err != nil { - fmt.Printf("Error generating key: %v\n", err) - os.Exit(1) - } - - encoded := fmt.Sprintf("%x", key) - fmt.Println("Generated master key (keep this safe!):") - fmt.Println() - fmt.Println(" " + encoded) - fmt.Println() - fmt.Println("Set it as an environment variable:") - fmt.Println(" export DRAGONSCALE_MASTER_KEY=" + encoded) - fmt.Println() - fmt.Println("Or add to your shell profile (~/.bashrc, ~/.zshrc).") -} - -func secretAdd() { - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale secret add ") - return - } - name := os.Args[3] - - ss, err := loadSecretStore() - if err != nil { - fmt.Printf("Error loading secret store: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Enter value for %q (input hidden): ", name) - reader := bufio.NewReader(os.Stdin) - value, err := reader.ReadString('\n') - if err != nil { - fmt.Printf("Error reading input: %v\n", err) - os.Exit(1) - } - value = strings.TrimSpace(value) - - if value == "" { - fmt.Println("Error: secret value cannot be empty") - os.Exit(1) - } - - if err := ss.Set(name, []byte(value)); err != nil { - fmt.Printf("Error storing secret: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Secret %q stored (%s)\n", name, secretStorePath()) -} - -func secretList() { - ss, err := loadSecretStore() - if err != nil { - fmt.Printf("Error loading secret store: %v\n", err) - os.Exit(1) - } - - names := ss.List() - if len(names) == 0 { - fmt.Println("No secrets stored.") - fmt.Println("Add one with: dragonscale secret add ") - return - } - - fmt.Printf("\nStored secrets (%d):\n", len(names)) - for _, name := range names { - fmt.Printf(" - %s\n", name) - } -} - -func secretDelete() { - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale secret delete ") - return - } - name := os.Args[3] - - ss, err := loadSecretStore() - if err != nil { - fmt.Printf("Error loading secret store: %v\n", err) - os.Exit(1) - } - - if !ss.Has(name) { - fmt.Printf("Secret %q not found\n", name) - return - } - - if err := ss.Delete(name); err != nil { - fmt.Printf("Error deleting secret: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Secret %q deleted\n", name) -} - -func daemonSocketPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".dragonscale", "daemon.sock") -} - -func daemonPIDPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".dragonscale", "daemon.pid") -} - -func daemonCmd() { - if len(os.Args) < 3 { - daemonHelp() - return - } - - sub := os.Args[2] - switch sub { - case "start": - daemonStart() - case "stop": - daemonStop() - case "status": - daemonStatus() - case "--help", "-h": - daemonHelp() - default: - fmt.Printf("Unknown daemon command: %s\n", sub) - daemonHelp() - } -} - -func daemonHelp() { - fmt.Println("\nDaemon mode (Unix socket transport)") - fmt.Println() - fmt.Println("Usage: dragonscale daemon ") - fmt.Println() - fmt.Println("Subcommands:") - fmt.Println(" start Start the daemon (foreground)") - fmt.Println(" stop Stop a running daemon") - fmt.Println(" status Check daemon status") - fmt.Println() - fmt.Println("The daemon listens on ~/.dragonscale/daemon.sock and provides") - fmt.Println("tool execution services via the SecureBus.") -} - -func daemonStart() { - sockPath := daemonSocketPath() - pidPath := daemonPIDPath() - - if data, err := os.ReadFile(pidPath); err == nil { - fmt.Printf("Daemon PID file exists (%s): %s\n", pidPath, strings.TrimSpace(string(data))) - fmt.Println("If the daemon is not running, remove the PID file and try again:") - fmt.Printf(" rm %s\n", pidPath) - return - } - - server, err := securebus.NewSocketTransportServer(sockPath) - if err != nil { - fmt.Printf("Error creating socket: %v\n", err) - os.Exit(1) - } - defer server.Close() - - pid := os.Getpid() - home, _ := os.UserHomeDir() - _ = os.MkdirAll(filepath.Join(home, ".dragonscale"), 0700) - _ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", pid)), 0600) - defer os.Remove(pidPath) - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - ss, err := loadSecretStore() - if err != nil { - logger.WarnCF("daemon", "failed to load secret store", map[string]interface{}{"error": err.Error()}) - ss = nil - } - - registry := tools.NewToolRegistry() - sandbox := cfg.SandboxPath() - restrict := cfg.RestrictToSandbox() - registry.Register(tools.NewExecTool(sandbox, restrict)) - registry.Register(tools.NewReadFileTool(sandbox, restrict)) - registry.Register(tools.NewWriteFileTool(sandbox, restrict)) - registry.Register(tools.NewListDirTool(sandbox, restrict)) - registry.Register(tools.NewEditFileTool(sandbox, restrict)) - - capLookup := func(name string) (tools.ToolCapabilities, bool) { - t, ok := registry.Get(name) - if !ok { - return tools.ZeroCapabilities(), false - } - return tools.ExtractCapabilities(t), true - } - executor := func(ctx context.Context, name string, args map[string]interface{}) *tools.ToolResult { - return registry.Execute(ctx, name, args) - } - - busCfg := securebus.DefaultBusConfig() - secureBus := securebus.New(busCfg, ss, capLookup, executor) - defer secureBus.Close() - - fmt.Printf("dragonscale daemon started (pid=%d, socket=%s)\n", pid, sockPath) - fmt.Printf(" sandbox: %s\n", sandbox) - fmt.Printf(" tools: %d registered\n", len(registry.List())) - fmt.Println("Press Ctrl+C to stop.") - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - - errCh := make(chan error, 1) - go func() { - errCh <- server.Serve(func(srvCtx context.Context, req itr.ToolRequest) itr.ToolResponse { - return secureBus.Execute(srvCtx, req) - }) - }() - - select { - case <-ctx.Done(): - fmt.Println("\nShutting down daemon...") - case err := <-errCh: - if err != nil { - fmt.Printf("Daemon error: %v\n", err) - } - } -} - -func daemonStop() { - pidPath := daemonPIDPath() - - data, err := os.ReadFile(pidPath) - if err != nil { - fmt.Println("No daemon PID file found. Is the daemon running?") - return - } - - pidStr := strings.TrimSpace(string(data)) - var pid int - if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil { - fmt.Printf("Invalid PID in %s: %s\n", pidPath, pidStr) - return - } - - proc, err := os.FindProcess(pid) - if err != nil { - fmt.Printf("Could not find process %d: %v\n", pid, err) - _ = os.Remove(pidPath) - return - } - - if err := proc.Signal(os.Interrupt); err != nil { - fmt.Printf("Could not signal process %d: %v\n", pid, err) - fmt.Println("Removing stale PID file.") - _ = os.Remove(pidPath) - return - } - - fmt.Printf("Sent interrupt to daemon (pid=%d)\n", pid) -} - -func daemonStatus() { - pidPath := daemonPIDPath() - sockPath := daemonSocketPath() - - data, err := os.ReadFile(pidPath) - if err != nil { - fmt.Println("Daemon: not running (no PID file)") - return - } - - pidStr := strings.TrimSpace(string(data)) - fmt.Printf("Daemon PID: %s\n", pidStr) - - if _, err := os.Stat(sockPath); err == nil { - fmt.Printf("Socket: %s (exists)\n", sockPath) - } else { - fmt.Printf("Socket: %s (missing)\n", sockPath) - } - - var pid int - if _, err := fmt.Sscanf(pidStr, "%d", &pid); err == nil { - proc, err := os.FindProcess(pid) - if err == nil { - if err := proc.Signal(nil); err == nil { - fmt.Println("Status: running") - } else { - fmt.Println("Status: stale PID file (process not found)") - } - } - } -} - -func getConfigPath() string { - if p, err := config.DefaultConfigPath(); err == nil { - return p - } - // Fallback: legacy ~/.dragonscale/config.json for systems where XDG resolution fails. - home, _ := os.UserHomeDir() - return filepath.Join(home, ".dragonscale", "config.json") -} - -func setupCronTool(appCtx context.Context, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cronOpts ...cron.CronOption) *cron.CronService { - cronStorePath := filepath.Join(workspace, "cron", "jobs.json") - - cronService := cron.NewCronService(cronStorePath, nil, cronOpts...) - - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout) - agentLoop.RegisterTool(cronTool) - - cronService.SetOnJob(func(job *cron.CronJob) (string, error) { - jobCtx, jobCancel := context.WithTimeout(appCtx, execTimeout) - defer jobCancel() - result := cronTool.ExecuteJob(jobCtx, job) - return result, nil - }) - - return cronService -} - -func loadConfig() (*config.Config, error) { - return picoruntime.LoadResolvedConfig(picoruntime.LoadConfigOptions{ - BaseConfigPath: getConfigPath(), - }) -} - -func bootstrapAgentRuntime(appCtx context.Context, cfg *config.Config) (*picoruntime.RuntimeHandle, error) { - return picoruntime.Bootstrap(appCtx, cfg, picoruntime.BootstrapOptions{ - OutboundMode: picoruntime.OutboundModeNone, - }) -} - -// setupSecureBus wires the Isolated Tool Runtime into an AgentLoop. -// It loads (or lazily creates) the SecretStore from the dragonscale home directory -// and calls agentLoop.SetupSecureBus so all tool calls are routed through -// capability enforcement, secret injection, leak scanning, and audit logging. -// -// If ITR initialisation fails for any non-fatal reason it logs a warning and -// returns without attaching the bus — the agent continues in direct-execution mode. -// The returned closer must be called on shutdown when the bus is non-nil. -func setupSecureBus(agentLoop *agent.AgentLoop) (closer func()) { - cfgDir, err := config.ConfigDir() - if err != nil { - // Fallback to ~/.dragonscale if XDG resolution fails. - home, herr := os.UserHomeDir() - if herr != nil { - logger.WarnC("itr", "SecureBus: cannot determine config dir — running without ITR") - return func() {} - } - cfgDir = filepath.Join(home, ".dragonscale") - } - - secretsPath := filepath.Join(cfgDir, "secrets.json") - - // Always use EnvKeyring in runtime wiring. We intentionally avoid - // in-memory fallback keyrings in production execution paths. - keyring := security.NewEnvKeyring(security.MasterKeyEnvVar) - - ss, err := security.NewSecretStore(secretsPath, keyring) - if err != nil { - logger.WarnCF("itr", "SecureBus: failed to load secret store — running without secret injection", - map[string]interface{}{"error": err.Error()}) - ss = nil - } - if os.Getenv(security.MasterKeyEnvVar) == "" { - logger.WarnCF("itr", "SecureBus: master key env var is not set; secret injection requiring stored secrets will fail", - map[string]interface{}{"env_var": security.MasterKeyEnvVar}) - } - - bus := agentLoop.SetupSecureBus(ss, securebus.DefaultBusConfig()) - logger.InfoC("itr", "SecureBus enabled — tool calls routed through ITR") - return bus.Close -} - -func cronCmd() { - if len(os.Args) < 3 { - cronHelp() - return - } - - subcommand := os.Args[2] - - // Load config to get workspace path - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - return - } - - cronStorePath := filepath.Join(cfg.SandboxPath(), "cron", "jobs.json") - - switch subcommand { - case "list": - cronListCmd(cronStorePath) - case "add": - cronAddCmd(cronStorePath) - case "remove": - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale cron remove ") - return - } - cronRemoveCmd(cronStorePath, os.Args[3]) - case "enable": - cronEnableCmd(cronStorePath, false) - case "disable": - cronEnableCmd(cronStorePath, true) - default: - fmt.Printf("Unknown cron command: %s\n", subcommand) - cronHelp() - } -} - -func cronHelp() { - fmt.Println("\nCron commands:") - fmt.Println(" list List all scheduled jobs") - fmt.Println(" add Add a new scheduled job") - fmt.Println(" remove Remove a job by ID") - fmt.Println(" enable Enable a job") - fmt.Println(" disable Disable a job") - fmt.Println() - fmt.Println("Add options:") - fmt.Println(" -n, --name Job name") - fmt.Println(" -m, --message Message for agent") - fmt.Println(" -e, --every Run every N seconds") - fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')") - fmt.Println(" -d, --deliver Deliver response to channel") - fmt.Println(" --to Recipient for delivery") - fmt.Println(" --channel Channel for delivery") -} - -func cronListCmd(storePath string) { - cs := cron.NewCronService(storePath, nil) - jobs := cs.ListJobs(true) // Show all jobs, including disabled - - if len(jobs) == 0 { - fmt.Println("No scheduled jobs.") - return - } - - fmt.Println("\nScheduled Jobs:") - fmt.Println("----------------") - for _, job := range jobs { - var schedule string - if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil { - schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000) - } else if job.Schedule.Kind == "cron" { - schedule = job.Schedule.Expr - } else { - schedule = "one-time" - } - - nextRun := "scheduled" - if job.State.NextRunAtMS != nil { - nextTime := time.UnixMilli(*job.State.NextRunAtMS) - nextRun = nextTime.Format("2006-01-02 15:04") - } - - status := "enabled" - if !job.Enabled { - status = "disabled" - } - - fmt.Printf(" %s (%s)\n", job.Name, job.ID) - fmt.Printf(" Schedule: %s\n", schedule) - fmt.Printf(" Status: %s\n", status) - fmt.Printf(" Next run: %s\n", nextRun) - } -} - -func cronAddCmd(storePath string) { - name := "" - message := "" - var everySec *int64 - cronExpr := "" - deliver := false - channel := "" - to := "" - - args := os.Args[3:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "-n", "--name": - if i+1 < len(args) { - name = args[i+1] - i++ - } - case "-m", "--message": - if i+1 < len(args) { - message = args[i+1] - i++ - } - case "-e", "--every": - if i+1 < len(args) { - var sec int64 - fmt.Sscanf(args[i+1], "%d", &sec) - everySec = &sec - i++ - } - case "-c", "--cron": - if i+1 < len(args) { - cronExpr = args[i+1] - i++ - } - case "-d", "--deliver": - deliver = true - case "--to": - if i+1 < len(args) { - to = args[i+1] - i++ - } - case "--channel": - if i+1 < len(args) { - channel = args[i+1] - i++ - } - } - } - - if name == "" { - fmt.Println("Error: --name is required") - return - } - - if message == "" { - fmt.Println("Error: --message is required") - return - } - - if everySec == nil && cronExpr == "" { - fmt.Println("Error: Either --every or --cron must be specified") - return - } - - var schedule cron.CronSchedule - if everySec != nil { - everyMS := *everySec * 1000 - schedule = cron.CronSchedule{ - Kind: "every", - EveryMS: &everyMS, - } - } else { - schedule = cron.CronSchedule{ - Kind: "cron", - Expr: cronExpr, - } - } - - cs := cron.NewCronService(storePath, nil) - job, err := cs.AddJob(name, schedule, message, deliver, channel, to) - if err != nil { - fmt.Printf("Error adding job: %v\n", err) - return - } - - fmt.Printf("āœ“ Added job '%s' (%s)\n", job.Name, job.ID) -} - -func cronRemoveCmd(storePath, jobID string) { - cs := cron.NewCronService(storePath, nil) - if cs.RemoveJob(jobID) { - fmt.Printf("āœ“ Removed job %s\n", jobID) - } else { - fmt.Printf("āœ— Job %s not found\n", jobID) - } -} - -func cronEnableCmd(storePath string, disable bool) { - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale cron enable/disable ") - return - } - - jobID := os.Args[3] - cs := cron.NewCronService(storePath, nil) - enabled := !disable - - job := cs.EnableJob(jobID, enabled) - if job != nil { - status := "enabled" - if disable { - status = "disabled" - } - fmt.Printf("āœ“ Job '%s' %s\n", job.Name, status) - } else { - fmt.Printf("āœ— Job %s not found\n", jobID) - } -} - -func skillsHelp() { - fmt.Println("\nSkills commands:") - fmt.Println(" list List installed skills") - fmt.Println(" install Install skill from GitHub") - fmt.Println(" install-builtin Install all builtin skills to workspace") - fmt.Println(" list-builtin List available builtin skills") - fmt.Println(" remove Remove installed skill") - fmt.Println(" search Search available skills") - fmt.Println(" show Show skill details") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" dragonscale skills list") - fmt.Println(" dragonscale skills install sipeed/dragonscale-skills/weather") - fmt.Println(" dragonscale skills install-builtin") - fmt.Println(" dragonscale skills list-builtin") - fmt.Println(" dragonscale skills remove weather") -} - -func skillsListCmd(loader *skills.SkillsLoader) { - allSkills := loader.ListSkills() - - if len(allSkills) == 0 { - fmt.Println("No skills installed.") - return - } - - fmt.Println("\nInstalled Skills:") - fmt.Println("------------------") - for _, skill := range allSkills { - fmt.Printf(" āœ“ %s (%s)\n", skill.Name, skill.Source) - if skill.Description != "" { - fmt.Printf(" %s\n", skill.Description) - } - } -} - -func skillsInstallCmd(installer *skills.SkillInstaller) { - if len(os.Args) < 4 { - fmt.Println("Usage: dragonscale skills install ") - fmt.Println("Example: dragonscale skills install sipeed/dragonscale-skills/weather") - return - } - - repo := os.Args[3] - fmt.Printf("Installing skill from %s...\n", repo) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := installer.InstallFromGitHub(ctx, repo); err != nil { - fmt.Printf("āœ— Failed to install skill: %v\n", err) - os.Exit(1) - } - - fmt.Printf("āœ“ Skill '%s' installed successfully!\n", filepath.Base(repo)) -} - -func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { - fmt.Printf("Removing skill '%s'...\n", skillName) - - if err := installer.Uninstall(skillName); err != nil { - fmt.Printf("āœ— Failed to remove skill: %v\n", err) - os.Exit(1) - } - - fmt.Printf("āœ“ Skill '%s' removed successfully!\n", skillName) -} - -func skillsInstallBuiltinCmd(workspace string) { - builtinSkillsDir := "./dragonscale/skills" - workspaceSkillsDir := filepath.Join(workspace, "skills") - - fmt.Printf("Copying builtin skills to workspace...\n") - - skillsToInstall := []string{ - "weather", - "news", - "stock", - "calculator", - } - - for _, skillName := range skillsToInstall { - builtinPath := filepath.Join(builtinSkillsDir, skillName) - workspacePath := filepath.Join(workspaceSkillsDir, skillName) - - if _, err := os.Stat(builtinPath); err != nil { - fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err) - continue - } - - if err := os.MkdirAll(workspacePath, 0755); err != nil { - fmt.Printf("āœ— Failed to create directory for %s: %v\n", skillName, err) - continue - } - - if err := copyDirectory(builtinPath, workspacePath); err != nil { - fmt.Printf("āœ— Failed to copy %s: %v\n", skillName, err) - } - } - - fmt.Println("\nāœ“ All builtin skills installed!") - fmt.Println("Now you can use them in your workspace.") -} - -func skillsListBuiltinCmd() { - _, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - return - } - builtinSkillsDir, _ := config.SkillsDir() - - fmt.Println("\nAvailable Builtin Skills:") - fmt.Println("-----------------------") - - entries, err := os.ReadDir(builtinSkillsDir) - if err != nil { - fmt.Printf("Error reading builtin skills: %v\n", err) - return - } - - if len(entries) == 0 { - fmt.Println("No builtin skills available.") - return - } - - for _, entry := range entries { - if entry.IsDir() { - skillName := entry.Name() - skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md") - - description := "No description" - if _, err := os.Stat(skillFile); err == nil { - data, err := os.ReadFile(skillFile) - if err == nil { - content := string(data) - if idx := strings.Index(content, "\n"); idx > 0 { - firstLine := content[:idx] - if strings.Contains(firstLine, "description:") { - descLine := strings.Index(content[idx:], "\n") - if descLine > 0 { - description = strings.TrimSpace(content[idx+descLine : idx+descLine]) - } - } - } - } - } - status := "āœ“" - fmt.Printf(" %s %s\n", status, entry.Name()) - if description != "" { - fmt.Printf(" %s\n", description) - } - } - } -} - -func skillsSearchCmd(installer *skills.SkillInstaller) { - fmt.Println("Searching for available skills...") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - availableSkills, err := installer.ListAvailableSkills(ctx) - if err != nil { - fmt.Printf("āœ— Failed to fetch skills list: %v\n", err) - return - } - - if len(availableSkills) == 0 { - fmt.Println("No skills available.") - return - } - - fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) - fmt.Println("--------------------") - for _, skill := range availableSkills { - fmt.Printf(" šŸ“¦ %s\n", skill.Name) - fmt.Printf(" %s\n", skill.Description) - fmt.Printf(" Repo: %s\n", skill.Repository) - if skill.Author != "" { - fmt.Printf(" Author: %s\n", skill.Author) - } - if len(skill.Tags) > 0 { - fmt.Printf(" Tags: %v\n", skill.Tags) - } - fmt.Println() - } -} - -func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { - content, ok := loader.LoadSkill(skillName) - if !ok { - fmt.Printf("āœ— Skill '%s' not found\n", skillName) - return - } - - fmt.Printf("\nšŸ“¦ Skill: %s\n", skillName) - fmt.Println("----------------------") - fmt.Println(content) }