diff --git a/cmd/opsctl/main.go b/cmd/opsctl/main.go new file mode 100644 index 000000000..06e1d4c22 --- /dev/null +++ b/cmd/opsctl/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/app" + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/format" + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner" + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/tasks" + "github.com/spf13/cobra" +) + +type exitCodeError struct { + code int + err error +} + +func (e *exitCodeError) Error() string { + return e.err.Error() +} + +func collectEnvFromHost(keys []string) map[string]string { + extra := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := os.LookupEnv(key); ok { + extra[key] = value + } + } + return extra +} + +func printUsage(w io.Writer, a *app.App, appName string) { + _, _ = fmt.Fprintf(w, "Usage: %s [global flags] [args]\n\n", appName) + _, _ = fmt.Fprintf(w, "Global flags:\n") + _, _ = fmt.Fprintln(w, " --format string output mode: text, json, raw (default text)") + _, _ = fmt.Fprintln(w, " --json shortcut for --format json") + _, _ = fmt.Fprintln(w, " --raw shortcut for --format raw") + _, _ = fmt.Fprintln(w, " --no-color disable color output") + _, _ = fmt.Fprintln(w, " --quiet suppress non-error output") + _, _ = fmt.Fprintln(w, " --root string repository root path (default current directory)") + _, _ = fmt.Fprintln(w, " --cwd string working directory for command execution") + _, _ = fmt.Fprintln(w, " --timeout value command timeout (ex: 2m, 30s)") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Tasks:") + for _, line := range a.TaskHelp() { + _, _ = fmt.Fprintln(w, line) + } +} + +func parseOutputMode(raw, json bool, mode string) (format.OutputMode, error) { + if raw { + return format.OutputRaw, nil + } + if json { + return format.OutputJSON, nil + } + if mode == "" { + mode = string(format.OutputText) + } + switch format.OutputMode(mode) { + case format.OutputText, format.OutputJSON, format.OutputRaw: + return format.OutputMode(mode), nil + default: + return "", fmt.Errorf("invalid --format value %q", mode) + } +} + +func newOpsApp(root string) *app.App { + rootPath := app.EnsureRoot(root) + ops := app.New(runner.OSRunner{}, rootPath) + for _, t := range tasks.NewRegistry(rootPath) { + ops.Register(t) + } + ops.Register(tasks.NewHelpTask(ops)) + return ops +} + +func defaultRunEnvKeys() []string { + return []string{ + "GO", + "GOFLAGS", + "CGO_ENABLED", + "GOOS", + "GOARCH", + "DRAGONSCALE_EVAL_HOST_HOME", + "DRAGONSCALE_EVAL_BASE_CONFIG", + "DRAGONSCALE_EVAL_CONFIG", + "DRAGONSCALE_EVAL_DEBUG", + "DEVCONTAINER_EXEC", + "PLATFORM", + "ARCH", + "BINARY_NAME", + "BUILD_DIR", + "VERSION", + "FANTASY_VERSION", + "NAME", + "ARGS", + "WORKSPACE_DIR", + } +} + +func buildOpsctlCommand(stdout, stderr io.Writer, args []string) *cobra.Command { + var ( + formatValue string + jsonMode bool + rawMode bool + noColor bool + quiet bool + cwd string + rootFlag string + timeout time.Duration + ) + + root := &cobra.Command{ + Use: "opsctl [global flags] [args]", + Short: "opsctl is a thin CLI wrapper over repository operations", + Long: "opsctl is a thin CLI wrapper over repository operations.", + RunE: func(cobraCmd *cobra.Command, cmdArgs []string) error { + mode, err := parseOutputMode(rawMode, jsonMode, formatValue) + if err != nil { + return &exitCodeError{code: 2, err: err} + } + + resolvedRoot := app.EnsureRoot(rootFlag) + ops := newOpsApp(resolvedRoot) + + if len(cmdArgs) == 0 || cmdArgs[0] == "help" || cmdArgs[0] == "--help" || cmdArgs[0] == "-h" { + printUsage(cobraCmd.OutOrStdout(), ops, cobraCmd.Root().Name()) + return nil + } + + taskName := strings.TrimSpace(cmdArgs[0]) + if taskName == "" { + printUsage(cobraCmd.OutOrStdout(), ops, cobraCmd.Root().Name()) + return nil + } + taskArgs := cmdArgs[1:] + + if !ops.HasTask(taskName) { + return &exitCodeError{code: 2, err: fmt.Errorf("unknown command: %s", taskName)} + } + + ctx := &app.Context{ + Root: resolvedRoot, + Cwd: cwd, + Format: mode, + Quiet: quiet, + NoColor: noColor, + Timeout: timeout, + ExtraEnv: collectEnvFromHost(defaultRunEnvKeys()), + Stdout: stdout, + Stderr: stderr, + } + + result, runErr := ops.Run(context.Background(), taskName, taskArgs, ctx) + if err := format.Render(cobraCmd.OutOrStdout(), mode, result, ctx.Quiet); err != nil { + return &exitCodeError{code: 1, err: fmt.Errorf("failed to render output: %v", err)} + } + if runErr != nil { + return &exitCodeError{code: 1, err: runErr} + } + return nil + }, + SilenceErrors: true, + SilenceUsage: true, + } + + root.SetOut(stdout) + root.SetErr(stderr) + root.SetArgs(args) + + root.PersistentFlags().StringVar(&formatValue, "format", string(format.OutputText), "output mode: text, json, raw") + root.PersistentFlags().BoolVar(&jsonMode, "json", false, "shortcut for --format json") + root.PersistentFlags().BoolVar(&rawMode, "raw", false, "shortcut for --format raw") + root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output (reserved)") + root.PersistentFlags().BoolVar(&quiet, "quiet", false, "suppress non-error output") + root.PersistentFlags().StringVar(&rootFlag, "root", "", "repository root") + root.PersistentFlags().StringVar(&cwd, "cwd", "", "working directory for command execution") + root.PersistentFlags().DurationVar(&timeout, "timeout", 0, "command timeout") + + root.SetHelpFunc(func(cobraCmd *cobra.Command, _ []string) { + if _, err := parseOutputMode(rawMode, jsonMode, formatValue); err != nil { + _, _ = fmt.Fprintln(cobraCmd.ErrOrStderr(), err) + return + } + rootPath := app.EnsureRoot(rootFlag) + printUsage(cobraCmd.OutOrStdout(), newOpsApp(rootPath), cobraCmd.Root().Name()) + }) + + return root +} + +func run(args []string, stdout, stderr io.Writer) int { + command := buildOpsctlCommand(stdout, stderr, args) + if err := command.Execute(); err != nil { + if codeErr, ok := err.(*exitCodeError); ok { + _, _ = fmt.Fprintln(stderr, codeErr.err) + return codeErr.code + } + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +func main() { + if code := run(os.Args[1:], os.Stdout, os.Stderr); code != 0 { + os.Exit(code) + } +} diff --git a/cmd/opsctl/main_test.go b/cmd/opsctl/main_test.go new file mode 100644 index 000000000..cc98d87e2 --- /dev/null +++ b/cmd/opsctl/main_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseOutputMode(t *testing.T) { + mode, err := parseOutputMode(false, false, "") + require.NoError(t, err) + require.Equal(t, "text", string(mode)) + + mode, err = parseOutputMode(true, false, "text") + require.NoError(t, err) + require.Equal(t, "raw", string(mode)) + + mode, err = parseOutputMode(false, true, "text") + require.NoError(t, err) + require.Equal(t, "json", string(mode)) + + _, err = parseOutputMode(false, false, "invalid") + require.Error(t, err) +} + +func TestRunPrintsUsageWithoutArgs(t *testing.T) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + code := run([]string{}, out, errOut) + require.Equal(t, 0, code) + require.Contains(t, out.String(), "Usage:") + require.Contains(t, out.String(), "Tasks:") + require.Equal(t, "", errOut.String()) +} + +func TestRunUnknownCommand(t *testing.T) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + code := run([]string{"not-a-real-command"}, out, errOut) + require.Equal(t, 2, code) + require.Contains(t, errOut.String(), "unknown command: not-a-real-command") + require.Equal(t, "", out.String()) +} + +func TestRunInvalidFormat(t *testing.T) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + code := run([]string{"--format=bad", "help"}, out, errOut) + require.Equal(t, 2, code) + require.Contains(t, errOut.String(), "invalid --format value \"bad\"") +} + +func TestRunHelpAlias(t *testing.T) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + code := run([]string{"--help"}, out, errOut) + require.Equal(t, 0, code) + require.Contains(t, out.String(), "Usage:") + require.Equal(t, "", errOut.String()) +} + +func TestRunHelpIncludesAllAlias(t *testing.T) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + code := run([]string{"help"}, out, errOut) + require.Equal(t, 0, code) + require.Equal(t, "", errOut.String()) + require.Contains(t, out.String(), "all") + require.Contains(t, out.String(), "build") +} + +func TestMakefilePhonyTargetsAreAvailableInOpsctl(t *testing.T) { + makeTargets := parseMakefilePhonyTargets(t) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + code := run([]string{"--help"}, out, errOut) + require.Equal(t, 0, code) + require.Equal(t, "", errOut.String()) + + tasks := parseOpsctlHelpTasks(t, out.String()) + for _, target := range makeTargets { + require.Contains(t, tasks, target, "Makefile phony target missing from opsctl help: %s", target) + } +} + +func parseMakefilePhonyTargets(t *testing.T) []string { + t.Helper() + + makefile, err := findProjectMakefile() + require.NoError(t, err) + raw, err := os.ReadFile(makefile) + require.NoError(t, err) + lines := bytes.Split(raw, []byte("\n")) + + var targets []string + inPhony := false + for _, b := range lines { + line := string(b) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, ".PHONY:") { + inPhony = true + line = strings.TrimPrefix(trimmed, ".PHONY:") + } else if !inPhony { + continue + } else if strings.HasPrefix(trimmed, "#") { + inPhony = false + continue + } + + if !inPhony { + continue + } + + line = strings.TrimSpace(line) + if line == "" { + break + } + + if strings.HasSuffix(line, "\\") { + line = strings.TrimSuffix(line, "\\") + } else { + inPhony = false + } + + for _, target := range strings.Fields(line) { + if target == "\\" { + continue + } + targets = append(targets, target) + } + + if !strings.HasSuffix(string(b), "\\") { + break + } + } + + return targets +} + +func findProjectMakefile() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for i := 0; i < 6; i++ { + candidate := filepath.Join(dir, "Makefile") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", os.ErrNotExist +} + +func parseOpsctlHelpTasks(t *testing.T, output string) []string { + t.Helper() + + var tasks []string + inTasks := false + for _, line := range bytes.Split([]byte(output), []byte("\n")) { + text := string(line) + if strings.HasPrefix(text, "Tasks:") { + inTasks = true + continue + } + if !inTasks { + continue + } + if strings.HasPrefix(text, "environment:") { + break + } + text = strings.TrimSpace(text) + if text == "" || strings.HasPrefix(text, "available") { + continue + } + parts := strings.Fields(text) + if len(parts) == 0 || strings.HasPrefix(parts[0], "--") { + continue + } + tasks = append(tasks, parts[0]) + } + return tasks +} diff --git a/internal/opsctl/app/app.go b/internal/opsctl/app/app.go new file mode 100644 index 000000000..b9df3a7c0 --- /dev/null +++ b/internal/opsctl/app/app.go @@ -0,0 +1,193 @@ +package app + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/format" + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner" +) + +type Task interface { + Name() string + Description() string + Run(ctx context.Context, r runner.Runner, io *Context) (Result, error) +} + +type Result struct { + Task string + Command string + ExitCode int + Stdout string + Stderr string + Err string +} + +type Context struct { + Root string + Cwd string + Format format.OutputMode + Quiet bool + NoColor bool + Timeout time.Duration + ExtraEnv map[string]string + Argv []string + Stdout io.Writer + Stderr io.Writer + Debug bool + RunID string + DevContainer bool +} + +type App struct { + tasks map[string]Task + order []string + root string + runner runner.Runner + clock func() time.Time +} + +func New(r runner.Runner, root string) *App { + return &App{ + tasks: make(map[string]Task), + root: root, + runner: r, + clock: time.Now, + } +} + +func (a *App) Register(task Task) { + name := task.Name() + a.tasks[name] = task + a.order = append(a.order, name) +} + +func (a *App) Task(name string) (Task, bool) { + task, ok := a.tasks[name] + return task, ok +} + +func (a *App) List() []Task { + taskList := make([]Task, 0, len(a.tasks)) + seen := make(map[string]bool, len(a.tasks)) + for _, name := range a.order { + task, ok := a.tasks[name] + if !ok || seen[name] { + continue + } + seen[name] = true + taskList = append(taskList, task) + } + for name, task := range a.tasks { + if seen[name] { + continue + } + taskList = append(taskList, task) + } + sort.Slice(taskList, func(i, j int) bool { + return taskList[i].Name() < taskList[j].Name() + }) + return taskList +} + +func (a *App) HasTask(name string) bool { + _, ok := a.tasks[name] + return ok +} + +func (a *App) Run(ctx context.Context, name string, args []string, outCtx *Context) (format.TaskResult, error) { + task, ok := a.tasks[name] + if !ok { + return format.TaskResult{}, fmt.Errorf("unknown task: %s", name) + } + + ctxWithTimeout := ctx + start := a.clock() + if outCtx.Timeout > 0 { + var cancel context.CancelFunc + ctxWithTimeout, cancel = context.WithTimeout(ctx, outCtx.Timeout) + defer cancel() + } + + taskCtx := *outCtx + taskCtx.Cwd = nonEmpty(taskCtx.Cwd, taskCtx.Root) + taskCtx.Argv = args + + res, err := task.Run(ctxWithTimeout, a.runner, &taskCtx) + finish := a.clock() + success := err == nil && res.Err == "" + tr := format.TaskResult{ + Task: name, + Command: res.Command, + ExitCode: res.ExitCode, + Stdout: res.Stdout, + Stderr: res.Stderr, + Error: res.Err, + NoColor: outCtx.NoColor, + StartedAt: start, + EndedAt: finish, + DurationMS: int64(finish.Sub(start) / time.Millisecond), + Success: success, + } + if err != nil { + if strings.TrimSpace(res.Err) != "" { + tr.Error = res.Err + } else { + tr.Error = err.Error() + } + } + if tr.Error != "" && tr.ExitCode == 0 { + tr.ExitCode = 1 + } + return tr, err +} + +func nonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func (a *App) formatTaskHelp() []string { + lines := make([]string, 0, len(a.tasks)+1) + lines = append(lines, "available tasks:") + for _, t := range a.List() { + lines = append(lines, fmt.Sprintf(" %-20s %s", t.Name(), t.Description())) + } + lines = append(lines, "\nenvironment:") + lines = append(lines, " DRAGONSCALE_EVAL_HOST_HOME") + lines = append(lines, " DRAGONSCALE_EVAL_BASE_CONFIG") + lines = append(lines, " DRAGONSCALE_EVAL_CONFIG") + lines = append(lines, " DEVCONTAINER_EXEC") + lines = append(lines, " DRAGONSCALE_EVAL_DEBUG") + return lines +} + +func (a *App) TaskHelp() []string { + return a.formatTaskHelp() +} + +func DefaultRoot() string { + wd, err := os.Getwd() + if err == nil { + return wd + } + return "" +} + +func EnsureRoot(root string) string { + clean := filepath.Clean(root) + if clean == "." || clean == "" { + return DefaultRoot() + } + return clean +} diff --git a/internal/opsctl/app/app_test.go b/internal/opsctl/app/app_test.go new file mode 100644 index 000000000..c34123d5c --- /dev/null +++ b/internal/opsctl/app/app_test.go @@ -0,0 +1,114 @@ +package app + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner" + "github.com/stretchr/testify/require" +) + +func TestTaskRegistryAndDispatch(t *testing.T) { + root := t.TempDir() + ops := New(runner.OSRunner{}, root) + + task := &captureTask{ + result: Result{ + Task: "demo", + Command: "echo hi", + ExitCode: 0, + Stdout: "ok", + }, + } + ops.Register(task) + require.True(t, ops.HasTask("demo")) + require.False(t, ops.HasTask("missing")) + + tr, err := ops.Run(context.Background(), "demo", []string{"--flag", "value"}, &Context{Cwd: root}) + require.NoError(t, err) + require.True(t, task.called) + require.Equal(t, []string{"--flag", "value"}, task.receivedArgs) + require.Equal(t, root, task.receivedCtx.Cwd) + require.Equal(t, "demo", tr.Task) + require.Equal(t, 0, tr.ExitCode) + require.Equal(t, "ok", tr.Stdout) +} + +func TestRunPropagatesTaskError(t *testing.T) { + root := t.TempDir() + ops := New(runner.OSRunner{}, root) + taskErr := errors.New("boom") + resultErr := Result{Task: "failed", Command: "false", ExitCode: 2, Err: taskErr.Error()} + ops.Register(&captureTask{result: resultErr}) + + tr, err := ops.Run(context.Background(), "failed", []string{}, &Context{Root: root}) + require.Error(t, err) + require.Equal(t, "failed", tr.Task) + require.Equal(t, 2, tr.ExitCode) + require.Equal(t, "boom", tr.Error) +} + +func TestRunAppliesTimeoutContext(t *testing.T) { + root := t.TempDir() + ops := New(runner.OSRunner{}, root) + called := false + blocking := &captureTask{ + result: Result{Task: "slow", ExitCode: 0}, + withContext: func(ctx context.Context) { + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.True(t, time.Now().Before(deadline)) + called = true + }, + } + ops.Register(blocking) + + _, err := ops.Run(context.Background(), "slow", nil, &Context{Root: root, Timeout: 250 * time.Millisecond}) + require.NoError(t, err) + require.True(t, called) + require.True(t, blocking.called) +} + +func TestListIsStableWithoutDuplicateRegistration(t *testing.T) { + root := t.TempDir() + ops := New(runner.OSRunner{}, root) + ops.Register(&captureTask{result: Result{Task: "dup", ExitCode: 0}}) + ops.Register(&captureTask{result: Result{Task: "dup", ExitCode: 0}}) + + items := ops.List() + require.Len(t, items, 1) + require.Equal(t, "dup", items[0].Name()) +} + +type captureTask struct { + called bool + result Result + receivedArgs []string + receivedCtx *Context + withContext func(context.Context) +} + +func (t *captureTask) Name() string { + return t.result.Task +} + +func (t *captureTask) Description() string { + return "capture task" +} + +func (t *captureTask) Run(ctx context.Context, _ runner.Runner, c *Context) (Result, error) { + t.called = true + t.receivedArgs = append([]string{}, c.Argv...) + if c.Cwd != "" { + t.receivedCtx = &Context{Root: c.Root, Cwd: c.Cwd, Argv: append([]string{}, c.Argv...), Quiet: c.Quiet, Timeout: c.Timeout} + } + if t.withContext != nil { + t.withContext(ctx) + } + if t.result.Err != "" { + return t.result, errors.New(t.result.Err) + } + return t.result, nil +} diff --git a/internal/opsctl/format/format.go b/internal/opsctl/format/format.go new file mode 100644 index 000000000..39f8bc0ae --- /dev/null +++ b/internal/opsctl/format/format.go @@ -0,0 +1,121 @@ +package format + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/ZanzyTHEbar/dragonscale/internal/opsctl/runner" +) + +type OutputMode string + +const ( + OutputText OutputMode = "text" + OutputJSON OutputMode = "json" + OutputRaw OutputMode = "raw" +) + +type TaskResult struct { + Task string `json:"task"` + Command string `json:"command"` + ExitCode int `json:"exit_code"` + DurationMS int64 `json:"duration_ms"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Error string `json:"error,omitempty"` + Success bool `json:"success"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + Env []string `json:"env,omitempty"` + Mode OutputMode `json:"mode"` + NoColor bool `json:"no_color,omitempty"` +} + +func NewTaskResult(name string, cmd string, result runner.CommandResult, startedAt time.Time, err error) TaskResult { + tr := TaskResult{ + Task: name, + Command: cmd, + ExitCode: result.ExitCode, + DurationMS: int64(result.Duration / time.Millisecond), + Stdout: result.Stdout, + Stderr: result.Stderr, + StartedAt: startedAt, + EndedAt: startedAt.Add(result.Duration), + Mode: OutputText, + Success: err == nil && result.ExitCode == 0, + } + if err != nil { + tr.Error = err.Error() + } + return tr +} + +func Render(w io.Writer, mode OutputMode, res TaskResult, quiet bool) error { + res.Mode = mode + switch mode { + case OutputJSON: + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(res) + case OutputRaw: + if quiet { + return nil + } + prefix := statusPrefix(!res.NoColor, res.Success) + _, _ = fmt.Fprintf(w, "%s %s (%dms)\n", prefix, res.Task, res.DurationMS) + return nil + default: + if quiet { + return nil + } + status := "ok" + if !res.Success { + status = "failed" + } + status = formatStatus(!res.NoColor, res.Success) + _, _ = fmt.Fprintf(w, "%s: %s (exit=%d, duration=%dms)\n", res.Task, status, res.ExitCode, res.DurationMS) + if res.Error != "" { + _, _ = fmt.Fprintf(w, " error: %s\n", res.Error) + } + if strings.TrimSpace(res.Stdout) != "" { + _, _ = fmt.Fprintf(w, " stdout: %s\n", res.Stdout) + } + if strings.TrimSpace(res.Stderr) != "" { + _, _ = fmt.Fprintf(w, " stderr: %s\n", res.Stderr) + } + return nil + } +} + +func formatStatus(colored bool, success bool) string { + if !colored { + if !success { + return "failed" + } + return "ok" + } + if success { + return colorize("ok", "32") + } + return colorize("failed", "31") +} + +func statusPrefix(colored bool, success bool) string { + if !colored { + if !success { + return "[err]" + } + return "[ok]" + } + if success { + return colorize("[ok]", "32") + } + return colorize("[err]", "31") +} + +func colorize(text string, colorCode string) string { + return "\x1b[" + colorCode + "m" + text + "\x1b[0m" +} diff --git a/internal/opsctl/format/format_test.go b/internal/opsctl/format/format_test.go new file mode 100644 index 000000000..cd350b11d --- /dev/null +++ b/internal/opsctl/format/format_test.go @@ -0,0 +1,61 @@ +package format + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestRenderJSONIncludesTaskAndError(t *testing.T) { + buf := &bytes.Buffer{} + result := TaskResult{ + Task: "build", + Command: "go build", + ExitCode: 1, + Error: "failed", + Success: false, + StartedAt: time.Unix(1, 0), + EndedAt: time.Unix(2, 0), + } + + require.NoError(t, Render(buf, OutputJSON, result, false)) + decoded := TaskResult{} + require.NoError(t, json.NewDecoder(buf).Decode(&decoded)) + require.Equal(t, "build", decoded.Task) + require.Equal(t, "failed", decoded.Error) + require.Equal(t, 1, decoded.ExitCode) +} + +func TestRenderTextIncludesDetails(t *testing.T) { + buf := &bytes.Buffer{} + result := TaskResult{ + Task: "lint", + ExitCode: 0, + DurationMS: 123, + Stdout: "ok\n", + NoColor: true, + } + + require.NoError(t, Render(buf, OutputText, result, false)) + output := buf.String() + require.Contains(t, output, "lint") + require.Contains(t, output, "exit=0") + require.Contains(t, output, "stdout: ok") + require.NotContains(t, output, "\x1b[") +} + +func TestRenderRawHonorQuiet(t *testing.T) { + buf := &bytes.Buffer{} + result := TaskResult{Task: "run", ExitCode: 0, DurationMS: 42, Success: true} + require.NoError(t, Render(buf, OutputRaw, result, true)) + require.Equal(t, "", buf.String()) + + buf.Reset() + require.NoError(t, Render(buf, OutputRaw, result, false)) + require.True(t, strings.Contains(buf.String(), "run")) + require.True(t, strings.Contains(buf.String(), "(42ms)")) +} diff --git a/internal/opsctl/runner/runner.go b/internal/opsctl/runner/runner.go new file mode 100644 index 000000000..885dd60d5 --- /dev/null +++ b/internal/opsctl/runner/runner.go @@ -0,0 +1,96 @@ +package runner + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" +) + +type CommandSpec struct { + Name string + Args []string + Dir string + Env []string + InheritOutput bool +} + +type CommandResult struct { + Command string + ExitCode int + Stdout string + Stderr string + Duration time.Duration + Err error +} + +type Runner interface { + Run(ctx context.Context, spec CommandSpec) (CommandResult, error) +} + +type OSRunner struct{} + +func (r OSRunner) Run(ctx context.Context, spec CommandSpec) (CommandResult, error) { + start := time.Now() + cmd := exec.CommandContext(ctx, spec.Name, spec.Args...) + if spec.Dir != "" { + cmd.Dir = spec.Dir + } + + cmd.Env = append(os.Environ(), spec.Env...) + + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + if spec.InheritOutput { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + } + + err := cmd.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + + result := CommandResult{ + Command: spec.Name, + ExitCode: exitCode, + Duration: time.Since(start), + Err: err, + } + if !spec.InheritOutput { + result.Stdout = stdoutBuf.String() + result.Stderr = stderrBuf.String() + } + + if err != nil { + return result, fmt.Errorf("command failed: %w", err) + } + return result, nil +} + +type FakeRunner struct { + Calls []CommandSpec + Result CommandResult + Handler func(CommandSpec) CommandResult +} + +func (r *FakeRunner) Run(_ context.Context, spec CommandSpec) (CommandResult, error) { + r.Calls = append(r.Calls, spec) + if r.Handler != nil { + res := r.Handler(spec) + return res, res.Err + } + return r.Result, r.Result.Err +} diff --git a/internal/opsctl/runner/runner_test.go b/internal/opsctl/runner/runner_test.go new file mode 100644 index 000000000..26913f863 --- /dev/null +++ b/internal/opsctl/runner/runner_test.go @@ -0,0 +1,55 @@ +package runner + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFakeRunnerRecordsCalls(t *testing.T) { + called := false + r := &FakeRunner{ + Handler: func(spec CommandSpec) CommandResult { + called = true + return CommandResult{ + Command: "ok", + ExitCode: 11, + } + }, + } + + result, err := r.Run(context.Background(), CommandSpec{Name: "test", Args: []string{"--flag"}, Env: []string{"A=1"}}) + require.NoError(t, err) + require.True(t, called) + require.Len(t, r.Calls, 1) + require.Equal(t, "test", r.Calls[0].Name) + require.Equal(t, []string{"--flag"}, r.Calls[0].Args) + require.Equal(t, []string{"A=1"}, r.Calls[0].Env) + require.Equal(t, 11, result.ExitCode) +} + +func TestFakeRunnerPropagatesHandlerError(t *testing.T) { + r := &FakeRunner{ + Handler: func(_ CommandSpec) CommandResult { + err := errors.New("boom") + return CommandResult{Command: "bad", ExitCode: -1, Err: err} + }, + } + + result, err := r.Run(context.Background(), CommandSpec{Name: "bad"}) + require.Error(t, err) + require.Equal(t, "bad", result.Command) + require.Equal(t, -1, result.ExitCode) +} + +func TestOSRunnerReturnsCommandError(t *testing.T) { + r := OSRunner{} + result, err := r.Run(context.Background(), CommandSpec{Name: "sh", Args: []string{"-c", "echo stdout; echo stderr 1>&2; exit 3"}}) + require.Error(t, err) + require.Equal(t, "command failed: exit status 3", err.Error()) + require.Equal(t, 3, result.ExitCode) + require.Equal(t, "stdout\n", result.Stdout) + require.Equal(t, "stderr\n", result.Stderr) +} diff --git a/pkg/dragonscale/sdk/options.go b/pkg/dragonscale/sdk/options.go new file mode 100644 index 000000000..5db5f94cd --- /dev/null +++ b/pkg/dragonscale/sdk/options.go @@ -0,0 +1,31 @@ +package sdk + +type AgentOptions struct { + Message string + SessionKey string + Debug bool +} + +type GatewayOptions struct { + Debug bool +} + +type MigrateOptions struct { + DryRun bool + ConfigOnly bool + WorkspaceOnly bool + Force bool + Refresh bool + OpenClawHome string + DragonscaleHome string +} + +type CronAddOptions struct { + Name string + Message string + EveryMS *int64 + Cron string + Deliver bool + To string + Channel string +} diff --git a/pkg/dragonscale/sdk/sdk.go b/pkg/dragonscale/sdk/sdk.go new file mode 100644 index 000000000..8e3d15126 --- /dev/null +++ b/pkg/dragonscale/sdk/sdk.go @@ -0,0 +1,199 @@ +// Package sdk provides a reusable, IO-agnostic dragonscale service API. +package sdk + +import ( + "context" + "fmt" + "io" + "io/fs" + "runtime" +) + +const defaultLogo = "šŸ‰" + +// Service is the public entry point for CLI and other I/O adapters. +type Service struct { + Version string + GitCommit string + BuildTime string + GoVersion string + Logo string + EmbeddedFS fs.FS +} + +// CLIService contracts represent the public service API consumed by I/O adapters. +type CLIService interface { + VersionService + OnboardingService + AgentService + GatewayService + AuthService + CronService + SkillsService + SecretService + DaemonService + MemoryService + MigrationService + StatusService +} + +// NewService creates a configured service instance. +func NewService(opts ...Option) *Service { + svc := &Service{ + Version: "dev", + Logo: defaultLogo, + } + + for _, opt := range opts { + opt(svc) + } + + if svc.GoVersion == "" { + svc.GoVersion = runtime.Version() + } + + if svc.Logo == "" { + svc.Logo = defaultLogo + } + + return svc +} + +// Option configures a Service. +type Option func(*Service) + +// WithVersion sets version metadata. +func WithVersion(version, gitCommit, buildTime, goVersion string) Option { + return func(s *Service) { + s.Version = version + s.GitCommit = gitCommit + s.BuildTime = buildTime + s.GoVersion = goVersion + } +} + +// WithLogo sets the CLI logo glyph. +func WithLogo(logo string) Option { + return func(s *Service) { + s.Logo = logo + } +} + +// WithEmbeddedFS sets the embedded template filesystem. +func WithEmbeddedFS(fsys fs.FS) Option { + return func(s *Service) { + s.EmbeddedFS = fsys + } +} + +// VersionString returns the version text with optional git commit suffix. +func (s *Service) VersionString() string { + v := s.Version + if v == "" { + v = "dev" + } + if s.GitCommit != "" { + v += fmt.Sprintf(" (git: %s)", s.GitCommit) + } + return v +} + +// BuildInfo returns build metadata. +func (s *Service) BuildInfo() (build string, goVer string) { + build = s.BuildTime + goVer = s.GoVersion + return +} + +// OnboardingService defines onboarding operations. +type OnboardingService interface { + Onboard(context.Context, io.Reader, io.Writer) error +} + +// AgentService defines interactive and one-shot agent operations. +type AgentService interface { + Agent(context.Context, io.Reader, io.Writer, AgentOptions) error +} + +// GatewayService defines gateway startup and control operations. +type GatewayService interface { + Gateway(context.Context, io.Writer, GatewayOptions) error +} + +// VersionService defines version reporting operations. +type VersionService interface { + PrintVersion(context.Context, io.Writer) error +} + +// StatusService defines status reporting operations. +type StatusService interface { + Status(context.Context, io.Writer) error +} + +// MigrationService defines migration operations. +type MigrationService interface { + Migrate(context.Context, MigrateOptions, io.Writer) error +} + +// AuthService defines auth lifecycle operations. +type AuthService interface { + AuthLogin(context.Context, io.Reader, io.Writer, string, bool) error + AuthLogout(context.Context, io.Writer, string) error + AuthStatus(context.Context, io.Writer) error +} + +// CronService defines cron job operations. +type CronService interface { + CronList(context.Context, io.Writer) error + CronAdd(context.Context, io.Writer, CronAddOptions) error + CronRemove(context.Context, io.Writer, string) error + CronEnable(context.Context, io.Writer, string, bool) error +} + +// SkillsService defines skill lifecycle operations. +type SkillsService interface { + SkillsList(context.Context, io.Writer) error + SkillsListBuiltin(context.Context, io.Writer) error + SkillsInstall(context.Context, io.Writer, string) error + SkillsInstallBuiltin(context.Context, io.Writer, string) error + SkillsRemove(context.Context, io.Writer, string) error + SkillsSearch(context.Context, io.Writer) error + SkillsShow(context.Context, io.Writer, string) error +} + +// SecretService defines encrypted secret management operations. +type SecretService interface { + SecretInit(context.Context, io.Writer) error + SecretAdd(context.Context, io.Reader, io.Writer, string) error + SecretList(context.Context, io.Writer) error + SecretDelete(context.Context, io.Writer, string) error +} + +// DaemonService defines daemon operations. +type DaemonService interface { + DaemonStart(context.Context, io.Writer) error + DaemonStop(context.Context, io.Writer) error + DaemonStatus(context.Context, io.Writer) error +} + +// MemoryService defines memory system operations. +type MemoryService interface { + MemoryMigrateSessions(context.Context, io.Writer) error + MemoryDBStatus(context.Context, io.Writer) error +} + +var ( + _ VersionService = (*Service)(nil) + _ CLIService = (*Service)(nil) + _ OnboardingService = (*Service)(nil) + _ AgentService = (*Service)(nil) + _ GatewayService = (*Service)(nil) + _ StatusService = (*Service)(nil) + _ MigrationService = (*Service)(nil) + _ AuthService = (*Service)(nil) + _ CronService = (*Service)(nil) + _ SkillsService = (*Service)(nil) + _ SecretService = (*Service)(nil) + _ DaemonService = (*Service)(nil) + _ MemoryService = (*Service)(nil) +) diff --git a/pkg/dragonscale/sdk/service_ops.go b/pkg/dragonscale/sdk/service_ops.go new file mode 100644 index 000000000..7478127d8 --- /dev/null +++ b/pkg/dragonscale/sdk/service_ops.go @@ -0,0 +1,1003 @@ +package sdk + +import ( + "bufio" + "context" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "os/signal" + "path/filepath" + "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/logger" + "github.com/ZanzyTHEbar/dragonscale/pkg/migrate" + dragonruntime "github.com/ZanzyTHEbar/dragonscale/pkg/runtime" + "github.com/ZanzyTHEbar/dragonscale/pkg/security" + "github.com/ZanzyTHEbar/dragonscale/pkg/security/securebus" + "github.com/ZanzyTHEbar/dragonscale/pkg/state" + "github.com/ZanzyTHEbar/dragonscale/pkg/tools" + "github.com/ZanzyTHEbar/dragonscale/pkg/voice" + "github.com/chzyer/readline" +) + +func (s *Service) PrintVersion(ctx context.Context, out io.Writer) error { + _ = ctx + fmt.Fprintf(out, "%s dragonscale %s\n", s.Logo, s.VersionString()) + build, goVer := s.BuildInfo() + if build != "" { + fmt.Fprintf(out, " Build: %s\n", build) + } + if goVer != "" { + fmt.Fprintf(out, " Go: %s\n", goVer) + } + return nil +} + +func (s *Service) LoadConfig() (*config.Config, error) { + return dragonruntime.LoadResolvedConfig(dragonruntime.LoadConfigOptions{ + BaseConfigPath: s.getConfigPath(), + }) +} + +func (s *Service) Onboard(ctx context.Context, in io.Reader, out io.Writer) error { + _ = ctx + configPath := s.getConfigPath() + + if _, err := os.Stat(configPath); err == nil { + fmt.Fprintf(out, "Config already exists at %s\n", configPath) + fmt.Fprint(out, "Overwrite? (y/n): ") + response, err := readLine(in) + if err != nil { + return err + } + if response != "y" { + fmt.Fprintln(out, "Aborted.") + return nil + } + } + + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("error saving config: %w", err) + } + + if migErr := migrate.MigrateToXDG(""); migErr != nil { + fmt.Fprintf(out, "Warning: XDG migration failed: %v\n", migErr) + } + + s.createWorkspaceTemplates(cfg, out) + + fmt.Fprintf(out, "%s dragonscale is ready!\n", s.Logo) + + fmt.Fprint(out, "\nSet up encrypted secret storage? (y/n): ") + secretResponse, err := readLine(in) + if err != nil { + return err + } + if secretResponse == "y" || secretResponse == "Y" { + key, err := security.GenerateKey() + if err != nil { + fmt.Fprintf(out, "Error generating key: %v\n", err) + } else { + encoded := fmt.Sprintf("%x", key) + fmt.Fprintln(out, "\nGenerated master key (keep this safe!):") + fmt.Fprintln(out, " "+encoded) + fmt.Fprintln(out) + fmt.Fprintln(out, "Add to your shell profile:") + fmt.Fprintln(out, " export DRAGONSCALE_MASTER_KEY="+encoded) + fmt.Fprintln(out) + fmt.Fprintln(out, "Then store secrets with: dragonscale secret add ") + } + } + + fmt.Fprintln(out, "\nNext steps:") + fmt.Fprintln(out, " 1. Add your API key to", configPath) + fmt.Fprintln(out, " Get one at: https://openrouter.ai/keys") + fmt.Fprintln(out, " 2. Chat: dragonscale agent -m \"Hello!\"") + return nil +} + +func (s *Service) Migrate(ctx context.Context, opts MigrateOptions, out io.Writer) error { + _ = ctx + result, err := migrate.Run(migrate.Options{ + DryRun: opts.DryRun, + ConfigOnly: opts.ConfigOnly, + WorkspaceOnly: opts.WorkspaceOnly, + Force: opts.Force, + Refresh: opts.Refresh, + OpenClawHome: opts.OpenClawHome, + DragonScaleHome: opts.DragonscaleHome, + }) + if err != nil { + return err + } + + if !opts.DryRun { + migrate.PrintSummary(result) + } + return nil +} + +func (s *Service) Agent(ctx context.Context, in io.Reader, out io.Writer, opts AgentOptions) error { + if opts.Debug { + logger.SetLevel(logger.DEBUG) + fmt.Fprintln(out, "šŸ” Debug mode enabled") + } + + sessionKey := opts.SessionKey + if sessionKey == "" { + sessionKey = "cli:default" + } + + cfg, err := s.LoadConfig() + if err != nil { + return err + } + + appCtx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + + rt, err := s.BuildAgentRuntime(appCtx, cfg) + if err != nil { + return err + } + defer rt.Close() + + agentLoop := rt.AgentLoop() + closeBus := s.AttachSecureBus(agentLoop) + if closeBus != nil { + 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 opts.Message != "" { + response, err := agentLoop.ProcessDirect(appCtx, opts.Message, sessionKey) + if err != nil { + return err + } + fmt.Fprintf(out, "\n%s %s\n", s.Logo, response) + return nil + } + + fmt.Fprintf(out, "%s Interactive mode (Ctrl+C to exit)\n\n", s.Logo) + return s.interactiveMode(appCtx, in, out, agentLoop, sessionKey) +} + +func (s *Service) Gateway(ctx context.Context, out io.Writer, opts GatewayOptions) error { + if opts.Debug { + logger.SetLevel(logger.DEBUG) + fmt.Fprintln(out, "šŸ” Debug mode enabled") + } + + cfg, err := s.LoadConfig() + if err != nil { + return err + } + + appCtx, cancel := context.WithCancel(ctx) + defer cancel() + + rt, err := s.BuildAgentRuntime(appCtx, cfg) + if err != nil { + return err + } + defer rt.Close() + + agentLoop := rt.AgentLoop() + msgBus := rt.MessageBus() + closeBus := s.AttachSecureBus(agentLoop) + if closeBus != nil { + defer closeBus() + } + + fmt.Fprintln(out, "\nšŸ“¦ Agent Status:") + startupInfo := agentLoop.GetStartupInfo() + toolsInfo := startupInfo["tools"].(map[string]interface{}) + skillsInfo := startupInfo["skills"].(map[string]interface{}) + fmt.Fprintf(out, " • Tools: %d loaded\n", toolsInfo["count"]) + fmt.Fprintf(out, " • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"]) + + logger.InfoCF("agent", "Agent initialized", map[string]interface{}{ + "tools_count": toolsInfo["count"], + "skills_total": skillsInfo["total"], + "skills_available": skillsInfo["available"], + }) + + 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 := s.BuildCronTool(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...) + heartbeatService.SetBus(msgBus) + heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + if channel == "" || chatID == "" { + channel, chatID = "cli", "direct" + } + 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") + } + 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.Fprintf(out, "Error creating channel manager: %v\n", err) + return err + } + + 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.Fprintf(out, "āœ“ Channels enabled: %s\n", enabledChannels) + } else { + fmt.Fprintln(out, "⚠ Warning: No channels enabled") + } + + fmt.Fprintf(out, "āœ“ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Fprintln(out, "Press Ctrl+C to stop") + + if err := cronService.Start(); err != nil { + fmt.Fprintf(out, "Error starting cron service: %v\n", err) + } + fmt.Fprintln(out, "āœ“ Cron service started") + + if err := heartbeatService.Start(); err != nil { + fmt.Fprintf(out, "Error starting heartbeat service: %v\n", err) + } + fmt.Fprintln(out, "āœ“ 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.Fprintf(out, "Error starting device service: %v\n", err) + } else if cfg.Devices.Enabled { + fmt.Fprintln(out, "āœ“ Device event service started") + } + + if err := channelManager.StartAll(appCtx); err != nil { + fmt.Fprintf(out, "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.Fprintf(out, "āœ“ 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) + select { + case <-sigChan: + fmt.Fprintln(out, "\nShutting down...") + case <-appCtx.Done(): + fmt.Fprintln(out, "\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.Fprintln(out, "āœ“ Gateway stopped") + return nil +} + +func (s *Service) Status(ctx context.Context, out io.Writer) error { + _ = ctx + cfg, err := s.LoadConfig() + if err != nil { + fmt.Fprintf(out, "Error loading config: %v\n", err) + return err + } + + configPath := s.getConfigPath() + + fmt.Fprintf(out, "%s dragonscale Status\n", s.Logo) + fmt.Fprintf(out, "Version: %s\n", s.VersionString()) + build, _ := s.BuildInfo() + if build != "" { + fmt.Fprintf(out, "Build: %s\n", build) + } + fmt.Fprintln(out) + + 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 { + statusMsg := "authenticated" + if cred.IsExpired() { + statusMsg = "expired" + } else if cred.NeedsRefresh() { + statusMsg = "needs refresh" + } + fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, statusMsg) + } + } + + 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) + } + } + + return nil +} + +func (s *Service) AuthLogin(ctx context.Context, in io.Reader, out io.Writer, provider string, useDeviceCode bool) error { + _ = in + if provider == "" { + fmt.Fprintln(out, "Error: --provider is required") + fmt.Fprintln(out, "Supported providers: openai, anthropic") + return fmt.Errorf("provider is required") + } + + cfgPath := s.getConfigPath() + + switch provider { + case "openai": + 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 { + return err + } + if err := auth.SetCredential("openai", cred); err != nil { + return err + } + appCfg, err := s.LoadConfig() + if err == nil { + appCfg.Providers.OpenAI.AuthMethod = "oauth" + if err := config.SaveConfig(cfgPath, appCfg); err != nil { + fmt.Fprintf(out, "Warning: could not update config: %v\n", err) + } + } + fmt.Fprintln(out, "Login successful!") + if cred.AccountID != "" { + fmt.Fprintf(out, "Account: %s\n", cred.AccountID) + } + case "anthropic": + cred, err := auth.LoginPasteToken(provider, in) + if err != nil { + return err + } + if err := auth.SetCredential(provider, cred); err != nil { + return err + } + appCfg, err := s.LoadConfig() + if err == nil { + appCfg.Providers.Anthropic.AuthMethod = "token" + if err := config.SaveConfig(cfgPath, appCfg); err != nil { + fmt.Fprintf(out, "Warning: could not update config: %v\n", err) + } + } + fmt.Fprintf(out, "Token saved for %s!\n", provider) + default: + fmt.Fprintf(out, "Unsupported provider: %s\n", provider) + fmt.Fprintln(out, "Supported providers: openai, anthropic") + return fmt.Errorf("unsupported provider: %s", provider) + } + + return nil +} + +func (s *Service) AuthLogout(ctx context.Context, out io.Writer, provider string) error { + _ = ctx + cfgPath := s.getConfigPath() + if provider != "" { + if err := auth.DeleteCredential(provider); err != nil { + return err + } + appCfg, err := s.LoadConfig() + if err == nil { + switch provider { + case "openai": + appCfg.Providers.OpenAI.AuthMethod = "" + case "anthropic": + appCfg.Providers.Anthropic.AuthMethod = "" + } + config.SaveConfig(cfgPath, appCfg) + } + fmt.Fprintf(out, "Logged out from %s\n", provider) + return nil + } + + if err := auth.DeleteAllCredentials(); err != nil { + return err + } + appCfg, err := s.LoadConfig() + if err == nil { + appCfg.Providers.OpenAI.AuthMethod = "" + appCfg.Providers.Anthropic.AuthMethod = "" + _ = config.SaveConfig(cfgPath, appCfg) + } + fmt.Fprintln(out, "Logged out from all providers") + return nil +} + +func (s *Service) AuthStatus(ctx context.Context, out io.Writer) error { + _ = ctx + store, err := auth.LoadStore() + if err != nil { + fmt.Fprintf(out, "Error loading auth store: %v\n", err) + return err + } + if len(store.Credentials) == 0 { + fmt.Fprintln(out, "No authenticated providers.") + fmt.Fprintln(out, "Run: dragonscale auth login --provider ") + return nil + } + + fmt.Fprintln(out, "\nAuthenticated Providers:") + fmt.Fprintln(out, "------------------------") + for provider, cred := range store.Credentials { + status := "active" + if cred.IsExpired() { + status = "expired" + } else if cred.NeedsRefresh() { + status = "needs refresh" + } + fmt.Fprintf(out, " %s:\n", provider) + fmt.Fprintf(out, " Method: %s\n", cred.AuthMethod) + fmt.Fprintf(out, " Status: %s\n", status) + if cred.AccountID != "" { + fmt.Fprintf(out, " Account: %s\n", cred.AccountID) + } + if !cred.ExpiresAt.IsZero() { + fmt.Fprintf(out, " Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) + } + } + return nil +} + +func (s *Service) CronList(ctx context.Context, out io.Writer) error { + _ = ctx + cfg, err := s.LoadConfig() + if err != nil { + return err + } + storePath := filepath.Join(cfg.SandboxPath(), "cron", "jobs.json") + cs := cron.NewCronService(storePath, nil) + jobs := cs.ListJobs(true) + if len(jobs) == 0 { + fmt.Fprintln(out, "No scheduled jobs.") + return nil + } + + fmt.Fprintln(out, "\nScheduled Jobs:") + fmt.Fprintln(out, "----------------") + 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.Fprintf(out, " %s (%s)\n", job.Name, job.ID) + fmt.Fprintf(out, " Schedule: %s\n", schedule) + fmt.Fprintf(out, " Status: %s\n", status) + fmt.Fprintf(out, " Next run: %s\n", nextRun) + } + return nil +} + +func (s *Service) CronAdd(ctx context.Context, out io.Writer, opts CronAddOptions) error { + _ = ctx + cfg, err := s.LoadConfig() + if err != nil { + return err + } + storePath := filepath.Join(cfg.SandboxPath(), "cron", "jobs.json") + cs := cron.NewCronService(storePath, nil) + job, err := cs.AddJob(opts.Name, s.scheduleFromOptions(opts), opts.Message, opts.Deliver, opts.Channel, opts.To) + if err != nil { + return err + } + fmt.Fprintf(out, "āœ“ Added job '%s' (%s)\n", job.Name, job.ID) + return nil +} + +func (s *Service) CronRemove(ctx context.Context, out io.Writer, jobID string) error { + _ = ctx + cfg, err := s.LoadConfig() + if err != nil { + return err + } + storePath := filepath.Join(cfg.SandboxPath(), "cron", "jobs.json") + cs := cron.NewCronService(storePath, nil) + if cs.RemoveJob(jobID) { + fmt.Fprintf(out, "āœ“ Removed job %s\n", jobID) + } else { + fmt.Fprintf(out, "āœ— Job %s not found\n", jobID) + } + return nil +} + +func (s *Service) CronEnable(ctx context.Context, out io.Writer, jobID string, enabled bool) error { + _ = ctx + cfg, err := s.LoadConfig() + if err != nil { + return err + } + storePath := filepath.Join(cfg.SandboxPath(), "cron", "jobs.json") + cs := cron.NewCronService(storePath, nil) + job := cs.EnableJob(jobID, enabled) + if job != nil { + status := "enabled" + if !enabled { + status = "disabled" + } + fmt.Fprintf(out, "āœ“ Job '%s' %s\n", job.Name, status) + } else { + fmt.Fprintf(out, "āœ— Job %s not found\n", jobID) + } + return nil +} + +func (s *Service) scheduleFromOptions(opts CronAddOptions) cron.CronSchedule { + if opts.EveryMS != nil { + return cron.CronSchedule{Kind: "every", EveryMS: opts.EveryMS} + } + return cron.CronSchedule{Kind: "cron", Expr: opts.Cron} +} + +func (s *Service) createWorkspaceTemplates(cfg *config.Config, out io.Writer) { + identityDir, err := config.IdentityDir() + if err != nil { + fmt.Fprintf(out, "Error resolving identity dir: %v\n", err) + return + } + if err := s.seedEmbeddedIdentity(identityDir); err != nil { + fmt.Fprintf(out, "Error seeding identity files: %v\n", err) + } + + skillsDir, err := config.SkillsDir() + if err != nil { + fmt.Fprintf(out, "Error resolving skills dir: %v\n", err) + return + } + if err := s.seedEmbeddedSkills(skillsDir); err != nil { + fmt.Fprintf(out, "Error seeding skills: %v\n", err) + } + + _ = os.MkdirAll(cfg.SandboxPath(), 0755) +} + +func (s *Service) seedEmbeddedIdentity(identityDir string) error { + if s.EmbeddedFS == nil { + return nil + } + identityFiles := []string{"AGENT.md", "IDENTITY.md", "SOUL.md", "USER.md"} + for _, name := range identityFiles { + data, err := fs.ReadFile(s.EmbeddedFS, "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 +} + +func (s *Service) seedEmbeddedSkills(skillsDir string) error { + if s.EmbeddedFS == nil { + return nil + } + return fs.WalkDir(s.EmbeddedFS, "workspace/skills", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, readErr := fs.ReadFile(s.EmbeddedFS, 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 (s *Service) getConfigPath() string { + if p, err := config.DefaultConfigPath(); err == nil { + return p + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".dragonscale", "config.json") +} + +func (s *Service) 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 (s *Service) interactiveMode(ctx context.Context, in io.Reader, out io.Writer, agentLoop *agent.AgentLoop, sessionKey string) error { + prompt := fmt.Sprintf("%s You: ", s.Logo) + reader := bufio.NewReader(os.Stdin) + if in != nil { + reader = bufio.NewReader(in) + } + + rl, err := readline.NewEx(&readline.Config{ + Prompt: prompt, + HistoryFile: filepath.Join(os.TempDir(), ".dragonscale_history"), + HistoryLimit: 100, + InterruptPrompt: "^C", + EOFPrompt: "exit", + }) + + if err == nil { + defer rl.Close() + for { + line, readErr := rl.Readline() + if readErr != nil { + if readErr == readline.ErrInterrupt || readErr == io.EOF { + fmt.Fprintln(out, "\nGoodbye!") + return nil + } + fmt.Fprintf(out, "Error reading input: %v\n", readErr) + continue + } + input := strings.TrimSpace(line) + if input == "" { + continue + } + if input == "exit" || input == "quit" { + fmt.Fprintln(out, "Goodbye!") + return nil + } + response, respErr := agentLoop.ProcessDirect(ctx, input, sessionKey) + if respErr != nil { + fmt.Fprintf(out, "Error: %v\n", respErr) + continue + } + fmt.Fprintf(out, "\n%s %s\n\n", s.Logo, response) + } + } + + fmt.Fprintln(out, "Falling back to simple input mode...") + for { + fmt.Fprint(out, prompt) + line, readErr := reader.ReadString('\n') + if readErr != nil { + if readErr == io.EOF { + fmt.Fprintln(out, "\nGoodbye!") + return nil + } + fmt.Fprintf(out, "Error reading input: %v\n", readErr) + continue + } + input := strings.TrimSpace(line) + if input == "" { + continue + } + if input == "exit" || input == "quit" { + fmt.Fprintln(out, "Goodbye!") + return nil + } + response, respErr := agentLoop.ProcessDirect(ctx, input, sessionKey) + if respErr != nil { + fmt.Fprintf(out, "Error: %v\n", respErr) + continue + } + fmt.Fprintf(out, "\n%s %s\n\n", s.Logo, response) + } +} + +// BuildAgentRuntime returns a configured runtime handle for command and adapter operations. +func (s *Service) BuildAgentRuntime(appCtx context.Context, cfg *config.Config) (*dragonruntime.RuntimeHandle, error) { + return s.bootstrapAgentRuntime(appCtx, cfg) +} + +// AttachSecureBus configures secure bus support for the provided agent loop and returns a close callback. +func (s *Service) AttachSecureBus(agentLoop *agent.AgentLoop) func() { + return s.setupSecureBus(agentLoop) +} + +// BuildCronTool registers cron tooling on the agent runtime and returns the cron service. +func (s *Service) BuildCronTool(appCtx context.Context, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrictToSandbox bool, execTimeout time.Duration, cronOpts ...cron.CronOption) *cron.CronService { + return s.setupCronTool(appCtx, agentLoop, msgBus, workspace, restrictToSandbox, execTimeout, cronOpts...) +} + +// DaemonSocketPath returns the active daemon socket path used by the current environment. +func (s *Service) DaemonSocketPath() string { + return s.daemonSocketPath() +} + +// DaemonPIDPath returns the active daemon PID file path used by the current environment. +func (s *Service) DaemonPIDPath() string { + return s.daemonPIDPath() +} + +// ConfigPath resolves the active config path for this service. +func (s *Service) ConfigPath() string { + return s.getConfigPath() +} + +func (s *Service) 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 (s *Service) bootstrapAgentRuntime(appCtx context.Context, cfg *config.Config) (*dragonruntime.RuntimeHandle, error) { + return dragonruntime.Bootstrap(appCtx, cfg, dragonruntime.BootstrapOptions{ + OutboundMode: dragonruntime.OutboundModeNone, + }) +} + +func (s *Service) setupSecureBus(agentLoop *agent.AgentLoop) (closer func()) { + cfgDir, err := config.ConfigDir() + if err != nil { + 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") + 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 (s *Service) daemonSocketPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".dragonscale", "daemon.sock") +} + +func (s *Service) daemonPIDPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".dragonscale", "daemon.pid") +} + +func readLine(in io.Reader) (string, error) { + reader := bufio.NewReader(in) + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} diff --git a/pkg/dragonscale/sdk/service_ops_extras.go b/pkg/dragonscale/sdk/service_ops_extras.go new file mode 100644 index 000000000..0f8213cde --- /dev/null +++ b/pkg/dragonscale/sdk/service_ops_extras.go @@ -0,0 +1,681 @@ +package sdk + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/ZanzyTHEbar/dragonscale/pkg" + "github.com/ZanzyTHEbar/dragonscale/pkg/config" + "github.com/ZanzyTHEbar/dragonscale/pkg/itr" + "github.com/ZanzyTHEbar/dragonscale/pkg/logger" + "github.com/ZanzyTHEbar/dragonscale/pkg/memory" + "github.com/ZanzyTHEbar/dragonscale/pkg/memory/delegate" + "github.com/ZanzyTHEbar/dragonscale/pkg/security" + "github.com/ZanzyTHEbar/dragonscale/pkg/security/securebus" + "github.com/ZanzyTHEbar/dragonscale/pkg/skills" + "github.com/ZanzyTHEbar/dragonscale/pkg/tools" +) + +func (s *Service) SkillsList(ctx context.Context, out io.Writer) error { + _ = ctx + + cfgDir, err := config.ConfigDir() + if err != nil { + return err + } + + skillsPath, err := config.SkillsDir() + if err != nil { + return err + } + + registry := skills.NewSkillsLoader(skillsPath, filepath.Join(cfgDir, "skills"), filepath.Join(cfgDir, "skills")) + + skillInfos := registry.ListSkills() + if len(skillInfos) == 0 { + fmt.Fprintln(out, "No skills installed.") + return nil + } + sort.Slice(skillInfos, func(i, j int) bool { + return skillInfos[i].Name < skillInfos[j].Name + }) + + for _, info := range skillInfos { + content, ok := registry.LoadSkill(info.Name) + if !ok { + fmt.Fprintf(out, `%s: unable to load (not found)\n`, info.Name) + continue + } + + description := strings.TrimSpace(skillDescriptionFromMarkdownData([]byte(content))) + if description == "" { + description = "(no description)" + } + fmt.Fprintf(out, `%s\n %s\n`, info.Name, description) + } + + return nil +} + +func (s *Service) SkillsListBuiltin(ctx context.Context, out io.Writer) error { + _ = ctx + + if s.EmbeddedFS != nil { + entries, err := fs.ReadDir(s.EmbeddedFS, "workspace/skills") + if err == nil { + if len(entries) == 0 { + fmt.Fprintln(out, "No builtin skills found.") + return nil + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + docPath := filepath.Join("workspace/skills", entry.Name(), "SKILL.md") + description := skillDescriptionFromMarkdownFS(s.EmbeddedFS, docPath) + fmt.Fprintf(out, `%s\n %s\n`, entry.Name(), description) + } + return nil + } + } + + builtinDir := filepath.Join(s.getConfigPath(), "skills") + if err := s.printBuiltinFromPath(out, builtinDir); err == nil { + return nil + } + + legacyBuiltin := filepath.Join(filepath.Dir(s.getConfigPath()), "skills") + if err := s.printBuiltinFromPath(out, legacyBuiltin); err == nil { + return nil + } + + return fmt.Errorf(`no builtin skills source available`) +} + +func (s *Service) printBuiltinFromPath(out io.Writer, dir string) error { + info, err := os.Stat(dir) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("builtin skills path is not a directory: %s", dir) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + if len(entries) == 0 { + fmt.Fprintln(out, "No builtin skills found.") + return nil + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + skillPath := filepath.Join(dir, entry.Name()) + docPath := filepath.Join(skillPath, "SKILL.md") + description := skillDescriptionFromMarkdown(docPath) + fmt.Fprintf(out, `%s\n %s\n`, entry.Name(), description) + } + + return nil +} + +func (s *Service) SkillsInstall(ctx context.Context, out io.Writer, repo string) error { + if strings.TrimSpace(repo) == "" { + return fmt.Errorf(`repository path required`) + } + + skillsPath, err := config.SkillsDir() + if err != nil { + return err + } + + installer := skills.NewSkillInstaller(skillsPath) + + ctx2, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + if err := installer.InstallFromGitHub(ctx2, repo); err != nil { + return err + } + + fmt.Fprintf(out, `Installed skill %s\n`, repo) + return nil +} + +func (s *Service) SkillsInstallBuiltin(ctx context.Context, out io.Writer, workspace string) error { + _ = ctx + + if strings.TrimSpace(workspace) == "" { + cfg, err := s.LoadConfig() + if err != nil { + return err + } + workspace = cfg.WorkspacePath() + } + + target := filepath.Join(workspace, "skills") + if err := os.MkdirAll(target, 0o700); err != nil { + return fmt.Errorf(`create workspace skills directory: %w`, err) + } + + if s.EmbeddedFS != nil { + if err := s.seedEmbeddedSkills(target); err != nil { + return err + } + fmt.Fprintln(out, `Installed builtin skills into workspace skills directory.`) + return nil + } + + source := filepath.Join(filepath.Dir(s.getConfigPath()), "skills") + if _, err := os.Stat(source); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf(`embedded builtin skills are unavailable`) + } + return err + } + + if err := s.CopyDirectory(source, target); err != nil { + return err + } + + fmt.Fprintln(out, `Installed builtin skills into workspace skills directory.`) + return nil +} + +func (s *Service) SkillsRemove(ctx context.Context, out io.Writer, name string) error { + _ = ctx + + if strings.TrimSpace(name) == "" { + return fmt.Errorf(`skill name required`) + } + + skillsDir, err := config.SkillsDir() + if err != nil { + return err + } + + installer := skills.NewSkillInstaller(skillsDir) + + if err := installer.Uninstall(name); err != nil { + return err + } + + fmt.Fprintf(out, `Removed skill %s\n`, name) + return nil +} + +func (s *Service) SkillsSearch(ctx context.Context, out io.Writer) error { + installer := skills.NewSkillInstaller("") + + ctx2, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + skillsInfo, err := installer.ListAvailableSkills(ctx2) + if err != nil { + return err + } + + if len(skillsInfo) == 0 { + fmt.Fprintln(out, "No skills available.") + return nil + } + + for _, skill := range skillsInfo { + name := "unknown" + if skill.Name != "" { + name = skill.Name + } + description := "" + if skill.Description != "" { + description = skill.Description + } + fmt.Fprintf(out, `%s\n`, name) + if description != "" { + fmt.Fprintf(out, ` %s\n`, description) + } + } + + return nil +} + +func (s *Service) SkillsShow(ctx context.Context, out io.Writer, name string) error { + _ = ctx + + if strings.TrimSpace(name) == "" { + return fmt.Errorf(`skill name required`) + } + + skillDir, err := config.SkillsDir() + if err != nil { + return err + } + loader := skills.NewSkillsLoader(skillDir, "", "") + + content, ok := loader.LoadSkill(name) + if !ok { + return fmt.Errorf(`skill not found`) + } + + fmt.Fprintf(out, `Name: %s\n`, name) + if content != "" { + description := strings.TrimSpace(skillDescriptionFromMarkdownData([]byte(content))) + if description != "" { + fmt.Fprintf(out, `Desc: %s\n`, description) + } + } + fmt.Fprintf(out, `Content: %s\n`, content) + + return nil +} + +func (s *Service) SecretInit(ctx context.Context, out io.Writer) error { + _ = ctx + + key, err := security.GenerateKey() + if err != nil { + return err + } + encoded := fmt.Sprintf(`%x`, key) + + fmt.Fprintln(out, `Generated master key (keep this safe!):`) + fmt.Fprintln(out, ``) + fmt.Fprintln(out, ` `+encoded) + fmt.Fprintln(out, ``) + fmt.Fprintln(out, `Set it as an environment variable:`) + fmt.Fprintln(out, ` export `+security.MasterKeyEnvVar+`=`+encoded) + fmt.Fprintln(out, ``) + fmt.Fprintln(out, `Or add to your shell profile (~/.bashrc, ~/.zshrc).`) + return nil +} + +func (s *Service) SecretAdd(ctx context.Context, in io.Reader, out io.Writer, name string) error { + _ = ctx + + if strings.TrimSpace(name) == "" { + return fmt.Errorf(`secret name required`) + } + + store, err := s.secretStore() + if err != nil { + return fmt.Errorf(`error opening secret store: %w`, err) + } + + fmt.Fprintf(out, `Secret value: `) + raw, err := io.ReadAll(in) + if err != nil { + return err + } + + value := strings.TrimSpace(string(raw)) + if value == "" { + return fmt.Errorf(`secret value cannot be empty`) + } + + if err := store.Set(name, []byte(value)); err != nil { + return err + } + + fmt.Fprintf(out, `Secret %s saved.\n`, name) + return nil +} + +func (s *Service) SecretList(ctx context.Context, out io.Writer) error { + _ = ctx + + store, err := s.secretStore() + if err != nil { + return err + } + + names := store.List() + if len(names) == 0 { + fmt.Fprintln(out, "No secrets found.") + return nil + } + + for _, name := range names { + fmt.Fprintln(out, name) + } + + return nil +} + +func (s *Service) SecretDelete(ctx context.Context, out io.Writer, name string) error { + _ = ctx + + if strings.TrimSpace(name) == "" { + return fmt.Errorf(`secret name required`) + } + + store, err := s.secretStore() + if err != nil { + return err + } + + if err := store.Delete(name); err != nil { + return err + } + + fmt.Fprintf(out, `Deleted secret %s\n`, name) + return nil +} + +func (s *Service) DaemonStart(ctx context.Context, out io.Writer) error { + cfgDir, err := config.ConfigDir() + if err != nil { + return err + } + + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + return err + } + + socketPath := s.DaemonSocketPath() + pidPath := s.DaemonPIDPath() + + if pidData, err := os.ReadFile(pidPath); err == nil { + pidText := strings.TrimSpace(string(pidData)) + if pidText != "" { + fmt.Fprintf(out, `Daemon already running (pid %s).\n`, pidText) + } + return nil + } + + cfg, err := s.LoadConfig() + if err != nil { + return err + } + + svr, err := securebus.NewSocketTransportServer(socketPath) + if err != nil { + return err + } + defer func() { + _ = svr.Close() + }() + + busStore, err := s.secretStore() + if err != nil { + fmt.Fprintf(out, `Warning: secret store unavailable: %v\n`, err) + busStore = nil + } + + toolRegistry := tools.NewToolRegistry() + registerDefaultTools(s, toolRegistry, cfg) + + baseCtx := ctx + if baseCtx == nil { + baseCtx = context.Background() + } + daemonCtx, cancel := signal.NotifyContext(baseCtx, os.Interrupt) + ctx = daemonCtx + defer cancel() + + capabilityLookup := func(toolName string) (tools.ToolCapabilities, bool) { + tool, ok := toolRegistry.Get(toolName) + if !ok { + return tools.ZeroCapabilities(), false + } + return tools.ExtractCapabilities(tool), true + } + executeTool := func(execCtx context.Context, name string, args map[string]interface{}) *tools.ToolResult { + return toolRegistry.Execute(execCtx, name, args) + } + + bus := securebus.New(securebus.DefaultBusConfig(), busStore, capabilityLookup, executeTool) + defer bus.Close() + + srvErr := make(chan error, 1) + go func() { + srvErr <- svr.Serve(func(reqCtx context.Context, req itr.ToolRequest) itr.ToolResponse { + return bus.Execute(reqCtx, req) + }) + }() + + if err := os.WriteFile(pidPath, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { + return err + } + defer os.Remove(pidPath) + + logger.InfoF(`daemon started on socket`, map[string]interface{}{ + `socket_path`: socketPath, + }) + fmt.Fprintln(out, `Daemon started.`) + fmt.Fprintf(out, ` sandbox: %s\n`, cfg.SandboxPath()) + fmt.Fprintf(out, ` tools: %d registered\n`, len(toolRegistry.List())) + + select { + case <-ctx.Done(): + logger.Info(`Daemon shutdown requested`) + case err := <-srvErr: + if err != nil { + return fmt.Errorf(`daemon stopped with error: %w`, err) + } + } + + return nil +} + +func (s *Service) DaemonStop(ctx context.Context, out io.Writer) error { + _ = ctx + + pidPath := s.DaemonPIDPath() + data, err := os.ReadFile(pidPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(out, `Daemon is not running.`) + return nil + } + return err + } + + pidText := strings.TrimSpace(string(data)) + pid, err := strconv.Atoi(pidText) + if err != nil { + return fmt.Errorf(`invalid pid file content`) + } + + p, err := os.FindProcess(pid) + if err != nil { + return err + } + + if err := p.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf(`failed to stop daemon: %w`, err) + } + + if err := os.Remove(pidPath); err != nil { + fmt.Fprintf(out, `Could not remove pid file: %v\n`, err) + } + fmt.Fprintf(out, `Daemon stopped (pid %d)\n`, pid) + return nil +} + +func (s *Service) DaemonStatus(ctx context.Context, out io.Writer) error { + _ = ctx + + pidPath := s.DaemonPIDPath() + data, err := os.ReadFile(pidPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(out, `Daemon is not running.`) + return nil + } + return err + } + + pidText := strings.TrimSpace(string(data)) + if pidText == "" { + fmt.Fprintln(out, `Daemon is not running.`) + return nil + } + pid, err := strconv.Atoi(pidText) + if err != nil { + return fmt.Errorf(`invalid pid file content`) + } + + p, err := os.FindProcess(pid) + if err != nil { + fmt.Fprintf(out, `Daemon is not running (stale pid %d).\n`, pid) + return os.WriteFile(pidPath, nil, 0o600) + } + + if err := p.Signal(syscall.Signal(0)); err != nil { + fmt.Fprintf(out, `Daemon is not running (stale pid %d).\n`, pid) + _ = os.Remove(pidPath) + return nil + } + + fmt.Fprintf(out, `Daemon running with PID %d\n`, pid) + return nil +} + +func (s *Service) MemoryMigrateSessions(ctx context.Context, out io.Writer) error { + cfg, err := s.LoadConfig() + if err != nil { + return err + } + + dbPath := cfg.Memory.DBPath + if dbPath == "" { + dbPath, err = config.DefaultDBPath() + if err != nil { + return err + } + } + + delegate, err := delegate.NewLibSQLDelegate(dbPath) + if err != nil { + return fmt.Errorf(`memory delegate: %w`, err) + } + defer delegate.Close() + + if err := delegate.Init(context.Background()); err != nil { + return fmt.Errorf(`initialize memory delegate: %w`, err) + } + + result, err := memory.MigrateFileSessions(ctx, delegate, pkg.NAME, filepath.Join(cfg.WorkspacePath(), ".sessions")) + if err != nil { + return err + } + if result == nil { + fmt.Fprintln(out, `No sessions to migrate.`) + return nil + } + + fmt.Fprintf(out, `sessions_found=%d\n`, result.SessionsFound) + fmt.Fprintf(out, `sessions_migrated=%d\n`, result.SessionsMigrated) + fmt.Fprintf(out, `items_created=%d\n`, result.ItemsCreated) + fmt.Fprintf(out, `errors=%d\n`, result.Errors) + return nil +} + +func (s *Service) MemoryDBStatus(ctx context.Context, out io.Writer) error { + _ = ctx + + cfg, err := s.LoadConfig() + if err != nil { + return err + } + + dbPath := cfg.Memory.DBPath + if dbPath == "" { + dbPath, err = config.DefaultDBPath() + if err != nil { + return err + } + } + + if stat, err := os.Stat(dbPath); err == nil { + fmt.Fprintf(out, `SQLite DB exists at %s\n`, dbPath) + fmt.Fprintf(out, `Size: %d bytes\n`, stat.Size()) + return nil + } else if os.IsNotExist(err) { + fmt.Fprintf(out, `SQLite DB missing at %s\n`, dbPath) + return nil + } + + return err +} + +func (s *Service) secretStore() (*security.SecretStore, error) { + secretPath := filepath.Join(filepath.Dir(s.getConfigPath()), `secrets.json`) + var keyring security.KeyringProvider + + if secretKey := os.Getenv(security.MasterKeyEnvVar); secretKey != "" { + keyring = security.NewEnvKeyring(security.MasterKeyEnvVar) + } else { + keyring = security.NewNoopKeyring(nil) + } + + store, err := security.NewSecretStore(secretPath, keyring) + if err != nil { + return nil, err + } + + return store, nil +} + +func skillDescriptionFromMarkdownFS(fsys fs.FS, path string) string { + data, err := fs.ReadFile(fsys, path) + if err != nil { + return `No description` + } + return skillDescriptionFromMarkdownData(data) +} + +func skillDescriptionFromMarkdown(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return `No description` + } + return skillDescriptionFromMarkdownData(data) +} + +func skillDescriptionFromMarkdownData(data []byte) string { + for _, line := range strings.Split(string(data), `\n`) { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, `description:`) { + return strings.TrimSpace(strings.TrimPrefix(trimmed, `description:`)) + } + if strings.HasPrefix(trimmed, "#") { + return strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + } + } + return `No description` +} + +func registerDefaultTools(service *Service, registry *tools.ToolRegistry, cfg *config.Config) { + _ = service + sandbox := cfg.SandboxPath() + restrictToSandbox := cfg.RestrictToSandbox() + registerTool(registry, tools.NewExecTool(sandbox, restrictToSandbox)) + registerTool(registry, tools.NewReadFileTool(sandbox, restrictToSandbox)) + registerTool(registry, tools.NewWriteFileTool(sandbox, restrictToSandbox)) + registerTool(registry, tools.NewListDirTool(sandbox, restrictToSandbox)) + registerTool(registry, tools.NewEditFileTool(sandbox, restrictToSandbox)) +} + +func registerTool(registry *tools.ToolRegistry, tool tools.Tool) { + registry.Register(tool) +} diff --git a/pkg/eval/instrumentation/model.go b/pkg/eval/instrumentation/model.go new file mode 100644 index 000000000..11d9093af --- /dev/null +++ b/pkg/eval/instrumentation/model.go @@ -0,0 +1,118 @@ +package instrumentation + +import ( + "context" + "encoding/json" + "time" + + fantasy "charm.land/fantasy" +) + +type InstrumentedCall struct { + duration time.Duration + toolCalls []InstrumentedToolCall + usage fantasy.Usage +} + +type InstrumentedToolCall struct { + Name string `json:"-"` + Args map[string]interface{} `json:"-"` + Result string `json:"-"` +} + +type InstrumentedLanguageModel struct { + inner fantasy.LanguageModel + calls []InstrumentedCall + totalUsage fantasy.Usage +} + +var _ fantasy.LanguageModel = (*InstrumentedLanguageModel)(nil) + +func Wrap(inner fantasy.LanguageModel) *InstrumentedLanguageModel { + return &InstrumentedLanguageModel{ + inner: inner, + } +} + +func (m *InstrumentedLanguageModel) Provider() string { return m.inner.Provider() } +func (m *InstrumentedLanguageModel) Model() string { return m.inner.Model() } + +func (m *InstrumentedLanguageModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + return m.inner.GenerateObject(ctx, call) +} + +func (m *InstrumentedLanguageModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) { + return m.inner.StreamObject(ctx, call) +} + +func (m *InstrumentedLanguageModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + start := time.Now() + resp, err := m.inner.Generate(ctx, call) + duration := time.Since(start) + + instrCall := InstrumentedCall{duration: duration} + if err == nil && resp != nil { + instrCall.usage = resp.Usage + m.totalUsage.InputTokens += resp.Usage.InputTokens + m.totalUsage.OutputTokens += resp.Usage.OutputTokens + m.totalUsage.TotalTokens += resp.Usage.TotalTokens + m.totalUsage.ReasoningTokens += resp.Usage.ReasoningTokens + m.totalUsage.CacheReadTokens += resp.Usage.CacheReadTokens + + for _, tc := range resp.Content.ToolCalls() { + var args map[string]interface{} + _ = json.Unmarshal([]byte(tc.Input), &args) + instrCall.toolCalls = append(instrCall.toolCalls, InstrumentedToolCall{ + Name: tc.ToolName, + Args: args, + }) + } + } + + m.calls = append(m.calls, instrCall) + return resp, err +} + +func (m *InstrumentedLanguageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + start := time.Now() + stream, err := m.inner.Stream(ctx, call) + if err != nil { + m.calls = append(m.calls, InstrumentedCall{duration: time.Since(start)}) + return nil, err + } + + instrCall := InstrumentedCall{} + wrappedStream := func(yield func(fantasy.StreamPart) bool) { + stream(func(part fantasy.StreamPart) bool { + switch part.Type { + case fantasy.StreamPartTypeFinish: + instrCall.usage = part.Usage + m.totalUsage.InputTokens += part.Usage.InputTokens + m.totalUsage.OutputTokens += part.Usage.OutputTokens + m.totalUsage.TotalTokens += part.Usage.TotalTokens + m.totalUsage.ReasoningTokens += part.Usage.ReasoningTokens + m.totalUsage.CacheReadTokens += part.Usage.CacheReadTokens + case fantasy.StreamPartTypeToolCall: + var args map[string]interface{} + _ = json.Unmarshal([]byte(part.ToolCallInput), &args) + instrCall.toolCalls = append(instrCall.toolCalls, InstrumentedToolCall{ + Name: part.ToolCallName, + Args: args, + }) + } + return yield(part) + }) + instrCall.duration = time.Since(start) + m.calls = append(m.calls, instrCall) + } + + return wrappedStream, nil +} + +func (m *InstrumentedLanguageModel) Calls() []InstrumentedCall { + return m.calls +} + +func (m *InstrumentedLanguageModel) Usage() fantasy.Usage { + return m.totalUsage +} diff --git a/pkg/eval/instrumentation/trace.go b/pkg/eval/instrumentation/trace.go new file mode 100644 index 000000000..9323665ae --- /dev/null +++ b/pkg/eval/instrumentation/trace.go @@ -0,0 +1,88 @@ +package instrumentation + +import ( + "encoding/json" + "time" +) + +type Trace struct { + Output string `json:"output"` + Steps []TraceStep `json:"steps"` + Metrics Metrics `json:"metrics"` + Error string `json:"error,omitempty"` + SessionKey string `json:"session_key"` +} + +type TraceStep struct { + Index int `json:"index"` + Type string `json:"type"` + Tool string `json:"tool,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Result string `json:"result,omitempty"` + Duration int64 `json:"duration_ms,omitempty"` +} + +type Metrics struct { + TotalDurationMs int64 `json:"total_duration_ms"` + StepCount int `json:"step_count"` + ToolCallCount int `json:"tool_call_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` +} + +const maxEvalOutputBytes = 500 + +func BuildSteps(model *InstrumentedLanguageModel) []TraceStep { + var steps []TraceStep + index := 0 + for _, call := range model.Calls() { + steps = append(steps, TraceStep{ + Index: index, + Type: "llm_call", + Duration: call.duration.Milliseconds(), + }) + index++ + + for _, tc := range call.toolCalls { + argsRaw, _ := json.Marshal(tc.Args) + steps = append(steps, TraceStep{ + Index: index, + Type: "tool_call", + Tool: tc.Name, + Args: argsRaw, + Result: truncate(tc.Result, maxEvalOutputBytes), + }) + index++ + } + } + return steps +} + +func BuildMetrics(model *InstrumentedLanguageModel, duration time.Duration) Metrics { + usage := model.Usage() + metric := Metrics{ + TotalDurationMs: int64(duration / time.Millisecond), + StepCount: len(model.Calls()), + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + ReasoningTokens: usage.ReasoningTokens, + CacheReadTokens: usage.CacheReadTokens, + } + + for _, call := range model.Calls() { + metric.ToolCallCount += len(call.toolCalls) + } + + return metric +} + +func truncate(value string, maxLen int) string { + if len(value) <= maxLen { + return value + } + return value[:maxLen] + "...[truncated]" +}