diff --git a/CHANGELOG.md b/CHANGELOG.md index c451351..c530f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] ### Added +- **`riskkernel init`** — scaffolds a working starting point in one command: a `.env` + (provider key, default budget, the data dir where runs and crash-resume checkpoints + live) and a runnable, key-free `quickstart.py` (a governed loop the budget stops), + then prints the next steps. Never overwrites existing files. - **SDK: resume a run after a crash.** `Runtime.resume_run(run_id)` attaches to an existing governed run (it neither creates a new run nor cancels on error), so a Python agent can pick its work back up from the last checkpoint after a `SIGKILL`. diff --git a/README.md b/README.md index 6d1d986..d6d9600 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ needed — and run it: ```bash go install github.com/prashar32/riskkernel/cmd/riskkernel@latest -riskkernel serve +riskkernel init # scaffold a .env + a runnable example in the current dir +riskkernel serve # start the daemon (reads .env) ``` (or `make build` from a clone). Deeper control (loops, checkpoints, approval diff --git a/cmd/riskkernel/init.go b/cmd/riskkernel/init.go new file mode 100644 index 0000000..0a95d8e --- /dev/null +++ b/cmd/riskkernel/init.go @@ -0,0 +1,62 @@ +package main + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" +) + +//go:embed templates/env.tmpl +var envTemplate string + +//go:embed templates/quickstart.py +var quickstartTemplate string + +// runInit scaffolds a working starting point — a .env config and a runnable +// governed-loop example — into the target directory (default "."). It never +// overwrites an existing file, so it's safe to re-run. +func runInit(args []string) error { + dir := "." + if len(args) > 0 && args[0] != "" { + dir = args[0] + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + + files := []struct{ name, content string }{ + {".env", envTemplate}, + {"quickstart.py", quickstartTemplate}, + } + var created, skipped []string + for _, f := range files { + path := filepath.Join(dir, f.name) + if _, err := os.Stat(path); err == nil { + skipped = append(skipped, f.name) + continue + } else if !os.IsNotExist(err) { + return fmt.Errorf("checking %s: %w", path, err) + } + if err := os.WriteFile(path, []byte(f.content), 0o600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + created = append(created, f.name) + } + + for _, name := range created { + fmt.Printf(" created %s\n", filepath.Join(dir, name)) + } + for _, name := range skipped { + fmt.Printf(" kept %s (already present)\n", filepath.Join(dir, name)) + } + + fmt.Print(` +Next: + 1. (optional) put your ANTHROPIC_API_KEY in .env — model calls need it; the loop demo doesn't + 2. riskkernel serve # start the governance daemon (reads .env) + 3. pip install "git+https://github.com/prashar32/riskkernel.git#subdirectory=sdks/python" + 4. python quickstart.py # watch the governor stop a runaway loop +`) + return nil +} diff --git a/cmd/riskkernel/init_test.go b/cmd/riskkernel/init_test.go new file mode 100644 index 0000000..e0790d3 --- /dev/null +++ b/cmd/riskkernel/init_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunInit_ScaffoldsWorkingFiles(t *testing.T) { + dir := t.TempDir() + if err := runInit([]string{dir}); err != nil { + t.Fatalf("init: %v", err) + } + for _, name := range []string{".env", "quickstart.py"} { + b, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("expected %s to be created: %v", name, err) + } + if len(b) == 0 { + t.Fatalf("%s is empty", name) + } + } + // The .env carries the budget + data-dir config; quickstart wires a governed run. + env, _ := os.ReadFile(filepath.Join(dir, ".env")) + for _, key := range []string{"RISKKERNEL_DEFAULT_DOLLARS", "RISKKERNEL_DATA_DIR", "ANTHROPIC_API_KEY"} { + if !strings.Contains(string(env), key) { + t.Fatalf(".env is missing %s:\n%s", key, env) + } + } + if qs, _ := os.ReadFile(filepath.Join(dir, "quickstart.py")); !strings.Contains(string(qs), "governed_run") { + t.Fatalf("quickstart.py doesn't wire a governed_run:\n%s", qs) + } +} + +func TestRunInit_DoesNotOverwrite(t *testing.T) { + dir := t.TempDir() + // A pre-existing .env with the user's own content must be preserved. + envPath := filepath.Join(dir, ".env") + mine := "ANTHROPIC_API_KEY=sk-mine\n" + if err := os.WriteFile(envPath, []byte(mine), 0o600); err != nil { + t.Fatal(err) + } + if err := runInit([]string{dir}); err != nil { + t.Fatalf("init: %v", err) + } + if got, _ := os.ReadFile(envPath); string(got) != mine { + t.Fatalf("init overwrote an existing .env: %q", got) + } + // …while the missing quickstart.py is still scaffolded. + if _, err := os.Stat(filepath.Join(dir, "quickstart.py")); err != nil { + t.Fatalf("quickstart.py should still be created: %v", err) + } +} diff --git a/cmd/riskkernel/main.go b/cmd/riskkernel/main.go index e522812..fdcd92b 100644 --- a/cmd/riskkernel/main.go +++ b/cmd/riskkernel/main.go @@ -2,6 +2,7 @@ // // Usage: // +// riskkernel init [dir] Scaffold a .env + runnable example to get started. // riskkernel serve Run the governance daemon (default port 7070). // riskkernel chat "" One-shot model call — proves the provider path. // riskkernel version Print build identity. @@ -36,6 +37,8 @@ func main() { var err error switch cmd { + case "init": + err = runInit(args) case "serve": err = runServe(args) case "chat": @@ -70,6 +73,7 @@ func usage() { fmt.Fprint(os.Stderr, `riskkernel — the risk engine for your AI agents Usage: + riskkernel init [dir] Scaffold a .env + runnable example to get started riskkernel serve Run the governance daemon (default :7070) riskkernel chat "" One-shot model call (proves the provider path) riskkernel runs list List persisted governed runs diff --git a/cmd/riskkernel/templates/env.tmpl b/cmd/riskkernel/templates/env.tmpl new file mode 100644 index 0000000..0b4dea2 --- /dev/null +++ b/cmd/riskkernel/templates/env.tmpl @@ -0,0 +1,21 @@ +# RiskKernel configuration — read by `riskkernel serve`. Secrets stay here; they +# are never written to the state store, never logged, never sent anywhere but the +# provider API. + +# Your provider key. The daemon still boots without it; only model calls need it. +ANTHROPIC_API_KEY= + +# Where the SQLite state lives: your runs, cost ledger, and crash-resume +# checkpoints. This file is yours — back it up to keep run history and resume. +RISKKERNEL_DATA_DIR=./data + +# Default per-run budget for runs created without an explicit one. Leave these +# unset for safe defaults ($5 / 100 loops / 1h per run); set any value to take +# control (0 means unlimited for that dimension). +RISKKERNEL_DEFAULT_DOLLARS= +RISKKERNEL_DEFAULT_LOOPS= +RISKKERNEL_DEFAULT_SECONDS= + +# Optional bearer token guarding the daemon's API. Empty = unauthenticated, for +# local use only; set one before exposing the port to anything untrusted. +RISKKERNEL_API_TOKEN= diff --git a/cmd/riskkernel/templates/quickstart.py b/cmd/riskkernel/templates/quickstart.py new file mode 100644 index 0000000..5b099ba --- /dev/null +++ b/cmd/riskkernel/templates/quickstart.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""A governed agent loop, scaffolded by `riskkernel init`. + +A deliberately runaway loop that RiskKernel's deterministic governor hard-stops at +its budget. No API key needed — the loop cap is enforced in the Go daemon, so you +can watch the kill with nothing running but `riskkernel serve`. + + riskkernel serve # in another terminal + python quickstart.py +""" + +import riskkernel as rk + +rt = rk.Runtime() # talks to the daemon at http://localhost:7070 + +with rt.governed_run(name="quickstart", budget=rt.budget(loops=8)) as run: + print(f"run {run.id} — budget: loops=8\n") + step = 0 + try: + while True: # a loop that would never stop on its own + run.step() # raises rk.BudgetExceeded at the cap + step += 1 + print(f" step {step}") + except rk.BudgetExceeded as halt: + print(f"\nRiskKernel stopped it — {halt.reason}. The governor capped the loop at 8;") + print("the loop never decided to stop. Swap the budget, wrap your own agent, and go.")