feat: add dragonscale SDK, eval instrumentation, and opsctl

- pkg/dragonscale/sdk: Service abstraction for agent ops, versioning,
  embedded workspace; used by CLI commands
- pkg/eval/instrumentation: Model and trace instrumentation for eval harness
- internal/opsctl: Ops automation (app, runner, format, tasks)
- cmd/opsctl: Standalone opsctl binary for automation workflows

Register help task in opsctl so Makefile phony targets (including help)
are available in opsctl --help output.
This commit is contained in:
ZanzyTHEbar 2026-02-22 15:28:55 +00:00
parent 946360afce
commit 7f6298e240
14 changed files with 3167 additions and 0 deletions

215
cmd/opsctl/main.go Normal file
View file

@ -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] <command> [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] <command> [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)
}
}

192
cmd/opsctl/main_test.go Normal file
View file

@ -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
}

193
internal/opsctl/app/app.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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)"))
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

199
pkg/dragonscale/sdk/sdk.go Normal file
View file

@ -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)
)

File diff suppressed because it is too large Load diff

View file

@ -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)
}

View file

@ -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
}

View file

@ -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]"
}