diff --git a/Makefile b/Makefile index 5fbd9b1..230e57e 100644 --- a/Makefile +++ b/Makefile @@ -29,11 +29,14 @@ LDFLAGS = -s -w \ # tool via //go:embed # - frontend/public/docs.md → served by Vite at /docs.md so the Docs page's # "Copy as Markdown" button reads the same bytes +# - cli/commands/reference.md → embedded by the `orva docs` command via +# //go:embed so the slim CLI renders the same reference offline # Single source of truth lives at docs/reference.md. Edit it, run -# `make docs-embed`, and both UI + MCP serve the new content. +# `make docs-embed`, and UI + MCP + CLI serve the new content. docs-embed: @cp docs/reference.md backend/internal/mcp/reference.md @cp docs/reference.md frontend/public/docs.md + @cp docs/reference.md cli/commands/reference.md # Copy adapter sources + bundled SDK into backend/cmd/orva/adapters/ so # //go:embed has them at build time. Keeps backend/runtimes/ as the diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index be4f94b..e50c55d 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -15,12 +15,15 @@ cli/ ├── output.go # shared output framework (stdout/stderr split, table|json, color, confirm) ├── activity.go # `orva activity` ├── channels.go # `orva channels …` + ├── chat.go # `orva chat` (interactive AI REPL + one-shot -p, SSE) ├── completion.go # `orva completion {bash|zsh|fish|powershell}` + ├── completions.go # dynamic shell completions (fn names, runtimes, models) ├── cron.go # `orva cron …` ├── deploy.go # `orva deploy [--watch]` ├── deployments.go # `orva deployments list/get/logs` ├── diff.go # `orva diff ` (unified diff between deployments) ├── dns.go # `orva dns get/set` + ├── docs.go # `orva docs` (renders embedded docs/reference.md) ├── firewall.go # `orva firewall list/add/enable/disable/delete/resolve` ├── fixtures.go # `orva fixtures list/get/save/delete/test` ├── functions.go # `orva functions …` @@ -38,7 +41,10 @@ cli/ ├── traces.go # `orva traces list/get/baseline` ├── upgrade.go # `orva upgrade` (self-update via go-selfupdate) ├── webhooks.go # `orva webhooks …` - └── commands_test.go # command-tree + flag-presence tests + ├── commands_test.go # command-tree + flag-presence tests + ├── chat_test.go # chat SSE drive + approval-flow tests (httptest) + ├── reference.md # GENERATED — embedded by docs.go (make docs-embed) + └── theme/ # lipgloss color palette (theme.New(enabled)) ``` The HTTP client and `~/.orva/config.yaml` loader live at `internal/client/` @@ -59,6 +65,44 @@ non-TTY without it). New commands should render through this layer rather than calling `fmt.Print*` directly, so the streams and formats stay consistent. +## Color & theming (`theme/`) + +Color lives in one place: `cli/commands/theme` wraps `charmbracelet/lipgloss` +(pure-Go, auto-degrades truecolor → 256 → 16 and adapts to light/dark +backgrounds). `theme.New(enabled)` returns a `*Styles`; when `enabled` is false +every style is a no-op pass-through that emits no ANSI. Commands get theirs via +`styles(cmd)` in `output.go`, which gates on the same `colorEnabled(cmd)` chain +(`--no-color` → `NO_COLOR` → JSON mode → non-TTY) — so color control stays a +single decision. `okf()` and the `diff` colorizer render through the theme. +**Don't** push ANSI into `tabwriter` cells: the escape bytes break column +alignment, so table bodies stay uncolored by design. + +## AI chat (`orva chat`) + +`chat.go` is the terminal front end to the AI assistant — the same agent the +dashboard drives, over `POST /api/v1/ai/chat` (SSE) with the CLI's existing API +key (the AI endpoints accept it; they need `admin`). Interactive REPL by +default, or one-shot with `-p` (supports `@file`/`@-`). It reuses `consumeSSE` +for the wire format and `client.Send(Request{..., Ctx, NoTimeout})` for a +cancellable streaming POST (Ctrl-C aborts the turn, not the process). + +- **Rendering:** assistant text streams raw to stdout live; on a TTY (not + `--raw`/`ORVA_CHAT_NO_GLAMOUR`) the message is re-rendered with `glamour` by + erasing the streamed block and reprinting — guarded by terminal-size checks, + degrades to raw if anything is uncertain. Thinking + tool status go to stderr; + stdout stays clean for piping. +- **Approvals:** the CLI never sets the policy; it reacts to `requires_approval` + /`awaiting_approval`. Write tools prompt `[y/N]`; non-interactive without + `--auto-approve` fails closed. The global `-y` is intentionally **not** honored + for AI tool approval. +- **Selection:** `--provider/--model/--thinking` are per-session overrides; + `/model` and `/thinking` persist via `PUT /api/v1/ai/selection`. + +Markdown rendering uses `glamour` (pulls `chroma`), which adds ~8 MB to the slim +binary (~12 MB → ~20 MB). The same `docs/reference.md` is embedded for +`orva docs` via `//go:embed reference.md`; `make docs-embed` keeps that copy in +sync alongside the MCP and frontend copies. + ## Build commands ```bash diff --git a/cli/commands/chat.go b/cli/commands/chat.go new file mode 100644 index 0000000..fa14eed --- /dev/null +++ b/cli/commands/chat.go @@ -0,0 +1,947 @@ +package commands + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + + "github.com/Harsh-2002/Orva/cli/commands/theme" + cli "github.com/Harsh-2002/Orva/internal/client" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// chatCmd is the terminal entry point to the Orva AI assistant — the same +// agent the dashboard's AI sidebar drives, reached over the AI SSE backend +// with the CLI's existing API key. It runs as an interactive streaming REPL, +// or one-shot with -p for inline/piped use. All AI configuration (providers, +// keys, approval policy) is done in the web UI; the CLI reads and respects it. +var chatCmd = &cobra.Command{ + Use: "chat [message]", + GroupID: groupAI, + Short: "Chat with the Orva AI assistant", + Long: `Talk to the Orva AI assistant from the terminal. + +Run with no arguments for an interactive streaming REPL, or pass -p (or a +positional message) for a one-shot reply that prints to stdout and exits — +pipe-friendly for scripting. + +The assistant can operate the instance end-to-end (list/deploy functions, +read logs, manage secrets, …). Write/destructive tools pause for your [y/N] +approval per the server's approval policy; reads and invokes run freely. + +Providers, API keys, the default model, and the approval policy are configured +in the web UI under Settings → AI. The CLI uses the saved selection; override +just for this session with --provider/--model/--thinking. + +Examples: + orva chat # interactive REPL + orva chat -p "list my functions" # one-shot, prints to stdout + echo "what failed today?" | orva chat -p @- + orva chat --model gpt-4o -p "summarize recent errors" + +REPL slash commands: /help /model /thinking /new /clear /yolo /exit`, + Args: cobra.ArbitraryArgs, + RunE: runChat, +} + +func init() { + f := chatCmd.Flags() + f.StringP("prompt", "p", "", "one-shot prompt (inline text, @file, or @- for stdin); non-interactive") + f.String("provider", "", "provider override for this session (default: saved selection)") + f.String("model", "", "model override for this session (default: saved selection)") + f.String("thinking", "", "reasoning effort: off | standard | deep") + f.String("conversation", "", "resume an existing conversation by id") + f.Bool("auto-approve", false, "auto-approve tool calls that would otherwise require confirmation (use with care)") + f.Bool("raw", false, "stream plain text; skip markdown (glamour) rendering") + + // Static completion for the thinking enum; --model is completed dynamically + // (see completions.go) once a provider is known. + _ = chatCmd.RegisterFlagCompletionFunc("thinking", fixedCompletion("off", "standard", "deep")) +} + +// ─── DTOs (mirror the backend AI JSON shapes) ─────────────────────────────── + +type aiSettings struct { + Provider string `json:"provider"` + Model string `json:"model"` + ThinkingLevel string `json:"thinking_level"` + ApprovalPolicy string `json:"approval_policy"` + ActiveProviderID string `json:"active_provider_id"` + ProviderModels map[string]string `json:"provider_models"` +} + +type providerView struct { + ID string `json:"id"` + Provider string `json:"provider"` + Label string `json:"label"` + HasKey bool `json:"has_key"` + Enabled bool `json:"enabled"` +} + +type modelInfo struct { + ID string `json:"id"` + Label string `json:"label"` +} + +// ─── session ──────────────────────────────────────────────────────────────── + +type chatSession struct { + cmd *cobra.Command + client *cli.Client + styles *theme.Styles + md *glamour.TermRenderer // nil when not rendering markdown + stdin *bufio.Reader + out io.Writer // data / assistant text (defaults to os.Stdout) + errOut io.Writer // status / chrome (defaults to os.Stderr) + + convID string + provider string + model string + thinking string + autoApprove bool + raw bool + interactive bool // REPL mode (vs one-shot) + + settings *aiSettings + providers []providerView + toolNames map[string]string // tool_call id → tool name (for result lines) +} + +// pendingTool is a tool call awaiting the operator's approve/reject decision. +type pendingTool struct { + ID string + Name string + Args json.RawMessage +} + +// turnResult captures the terminal state of one SSE stream (chat or an +// approve/reject continuation). +type turnResult struct { + pending []pendingTool + awaiting bool + done bool + note string + errMsg string +} + +func newChatSession(cmd *cobra.Command, client *cli.Client) *chatSession { + provider, _ := cmd.Flags().GetString("provider") + model, _ := cmd.Flags().GetString("model") + thinking, _ := cmd.Flags().GetString("thinking") + conv, _ := cmd.Flags().GetString("conversation") + auto, _ := cmd.Flags().GetBool("auto-approve") + raw, _ := cmd.Flags().GetBool("raw") + + s := &chatSession{ + cmd: cmd, + client: client, + styles: styles(cmd), + stdin: bufio.NewReader(os.Stdin), + out: os.Stdout, + errOut: os.Stderr, + convID: conv, + provider: provider, + model: model, + thinking: thinking, + autoApprove: auto, + raw: raw, + toolNames: map[string]string{}, + } + s.initRenderer() + return s +} + +// initRenderer builds the glamour markdown renderer for TTY stdout. It stays +// nil (plain streaming) when stdout isn't a terminal, when --raw is set, or +// when ORVA_CHAT_NO_GLAMOUR is set — the scripting/escape-hatch paths. +func (s *chatSession) initRenderer() { + if s.raw || !stdoutIsTerminal() || os.Getenv("ORVA_CHAT_NO_GLAMOUR") != "" { + return + } + w := s.termWidth() + if w <= 0 { + w = 80 + } + if w > 120 { + w = 120 + } + r, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(w)) + if err == nil { + s.md = r + } +} + +func (s *chatSession) renderMarkdown() bool { return s.md != nil } + +func (s *chatSession) termWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + return 0 + } + return w +} + +func (s *chatSession) termSize() (int, int) { + w, h, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + return 0, 0 + } + return w, h +} + +// ─── command entry ────────────────────────────────────────────────────────── + +func runChat(cmd *cobra.Command, args []string) error { + if t, _ := cmd.Flags().GetString("thinking"); t != "" && t != "off" && t != "standard" && t != "deep" { + return fmt.Errorf("invalid --thinking %q (want off | standard | deep)", t) + } + + // Resolve the one-shot prompt: -p (supports @file / @-) wins, else any + // positional args are joined into the prompt. + prompt := "" + if p, _ := cmd.Flags().GetString("prompt"); p != "" { + b, err := readBodyArg(p) + if err != nil { + return fmt.Errorf("read prompt: %w", err) + } + prompt = strings.TrimSpace(string(b)) + } else if len(args) > 0 { + prompt = strings.TrimSpace(strings.Join(args, " ")) + } + + client, err := getClient(cmd) + if err != nil { + return err + } + s := newChatSession(cmd, client) + if err := s.ensureProvider(); err != nil { + return err + } + + if prompt != "" { + return s.runTurn(cmd.Context(), prompt) + } + + if !term.IsTerminal(int(os.Stdin.Fd())) || !stdoutIsTerminal() { + return errors.New("interactive chat needs a terminal; use `orva chat -p \"...\"` for one-shot or piped use") + } + s.interactive = true + return s.repl(cmd.Context()) +} + +// ensureProvider verifies at least one usable provider is configured (so we +// fail with a clear message rather than an opaque SSE error) and caches the +// provider list + settings for the banner and pickers. +func (s *chatSession) ensureProvider() error { + provs, err := s.fetchProviders() + if err != nil { + return fmt.Errorf("load AI providers: %w", err) + } + usable := 0 + for _, p := range provs { + if p.Enabled && p.HasKey { + usable++ + } + } + if usable == 0 { + return errors.New("no AI provider configured — add one in the web UI under Settings → AI, then retry") + } + s.providers = provs + if st, err := s.fetchSettings(); err == nil { + s.settings = st + } + return nil +} + +// ─── REPL ─────────────────────────────────────────────────────────────────── + +func (s *chatSession) repl(parent context.Context) error { + s.printBanner() + for { + fmt.Fprint(s.errOut, s.styles.Prompt.Render("you ▸ ")) + line, err := s.stdin.ReadString('\n') + if err != nil { // Ctrl-D / EOF + fmt.Fprintln(s.errOut) + fmt.Fprintln(s.errOut, s.styles.Muted.Render("bye")) + return nil + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "/") { + if s.handleSlash(parent, line) { + return nil + } + continue + } + if err := s.runTurn(parent, line); err != nil && !errors.Is(err, context.Canceled) { + s.printError(err.Error()) + } + } +} + +func (s *chatSession) printBanner() { + provider, model, thinking, policy := s.activeProvider(), s.activeModel(), s.activeThinking(), s.activePolicy() + head := s.styles.Banner.Render("Orva AI") + meta := s.styles.Muted.Render(fmt.Sprintf("%s · %s · thinking:%s · approval:%s", provider, model, thinking, policy)) + fmt.Fprintf(s.errOut, "\n %s %s\n", head, meta) + fmt.Fprintln(s.errOut, s.styles.Muted.Render(" Type a message, /help for commands, Ctrl-D to exit.\n")) +} + +func (s *chatSession) activeProvider() string { + if s.provider != "" { + return s.provider + } + if s.settings != nil && s.settings.Provider != "" { + return s.settings.Provider + } + return "(default)" +} + +func (s *chatSession) activeModel() string { + if s.model != "" { + return s.model + } + if s.settings != nil && s.settings.Model != "" { + return s.settings.Model + } + return "(default)" +} + +func (s *chatSession) activeThinking() string { + if s.thinking != "" { + return s.thinking + } + if s.settings != nil && s.settings.ThinkingLevel != "" { + return s.settings.ThinkingLevel + } + return "standard" +} + +func (s *chatSession) activePolicy() string { + if s.settings != nil && s.settings.ApprovalPolicy != "" { + return s.settings.ApprovalPolicy + } + return "all_writes" +} + +// handleSlash runs a /command. It returns true to exit the REPL. +func (s *chatSession) handleSlash(parent context.Context, line string) bool { + fields := strings.Fields(line) + cmd := fields[0] + arg := strings.TrimSpace(strings.TrimPrefix(line, cmd)) + switch cmd { + case "/exit", "/quit": + return true + case "/help": + s.printHelp() + case "/new": + s.convID = "" + fmt.Fprintln(s.errOut, s.styles.Muted.Render("started a new conversation")) + case "/clear": + if stdoutIsTerminal() { + fmt.Fprint(s.out, "\x1b[2J\x1b[H") + } + case "/yolo": + s.autoApprove = !s.autoApprove + if s.autoApprove { + fmt.Fprintln(s.errOut, s.styles.Warn.Render("⚠ auto-approve ON — tool calls run without confirmation")) + } else { + fmt.Fprintln(s.errOut, s.styles.Muted.Render("auto-approve off")) + } + case "/thinking": + s.setThinking(arg) + case "/model": + if err := s.pickModel(); err != nil { + s.printError(err.Error()) + } + default: + fmt.Fprintln(s.errOut, s.styles.Muted.Render("unknown command — try /help")) + } + return false +} + +func (s *chatSession) printHelp() { + lines := []string{ + "/help show this help", + "/model choose provider + model (persists)", + "/thinking set reasoning effort: off | standard | deep", + "/new start a fresh conversation", + "/clear clear the screen", + "/yolo toggle auto-approve for tool calls", + "/exit leave the chat", + } + fmt.Fprintln(s.errOut) + for _, l := range lines { + fmt.Fprintln(s.errOut, " "+s.styles.Muted.Render(l)) + } + fmt.Fprintln(s.errOut) +} + +func (s *chatSession) setThinking(arg string) { + level := strings.TrimSpace(arg) + if level == "" { + fmt.Fprintln(s.errOut, s.styles.Muted.Render("usage: /thinking off | standard | deep")) + return + } + if level != "off" && level != "standard" && level != "deep" { + s.printError(fmt.Sprintf("invalid level %q (want off | standard | deep)", level)) + return + } + s.thinking = level + if err := s.putSelection("", "", "", level); err != nil { + s.printError(err.Error()) + return + } + fmt.Fprintln(s.errOut, s.styles.Muted.Render("thinking set to "+level)) +} + +// ─── turn execution + streaming ───────────────────────────────────────────── + +func (s *chatSession) runTurn(parent context.Context, content string) error { + ctx, cancel := s.turnContext(parent) + defer cancel() + + if s.interactive { + fmt.Fprintln(s.errOut, s.styles.Banner.Render("orva ▸")) + } + + resp, err := s.postChat(ctx, content) + if err != nil { + return s.classify(err) + } + res, err := s.drive(resp) + if err != nil { + return s.classify(err) + } + if res.awaiting && len(res.pending) > 0 { + res, err = s.handleApprovals(ctx, res.pending) + if err != nil { + return s.classify(err) + } + } + if res.note != "" { + fmt.Fprintln(s.errOut, s.styles.Muted.Render("("+res.note+")")) + } + if res.errMsg != "" { + s.printError(res.errMsg) + return errors.New(res.errMsg) + } + return nil +} + +// turnContext returns a context that is cancelled on Ctrl-C for the duration +// of one turn (chat stream + any approval continuations). Cancelling aborts the +// in-flight request rather than killing the process; an idle Ctrl-C at the +// prompt uses the default behavior (exit) because the handler is detached when +// the turn ends. +func (s *chatSession) turnContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + go func() { + select { + case <-sigCh: + cancel() + case <-ctx.Done(): + } + signal.Stop(sigCh) + }() + return ctx, cancel +} + +func (s *chatSession) classify(err error) error { + if errors.Is(err, context.Canceled) { + fmt.Fprintln(s.errOut, s.styles.Muted.Render("\n(interrupted)")) + return err + } + return err +} + +func (s *chatSession) postChat(ctx context.Context, content string) (*http.Response, error) { + body := map[string]string{"content": content} + if s.convID != "" { + body["conversation_id"] = s.convID + } + if s.provider != "" { + body["provider"] = s.provider + } + if s.model != "" { + body["model"] = s.model + } + if s.thinking != "" { + body["thinking"] = s.thinking + } + j, _ := json.Marshal(body) + return s.client.Send(cli.Request{ + Method: http.MethodPost, + Path: "/api/v1/ai/chat", + Accept: "text/event-stream", + ContentType: "application/json", + NoTimeout: true, + Ctx: ctx, + Body: bytes.NewReader(j), + }) +} + +// drive consumes one SSE stream, rendering events as they arrive, and returns +// its terminal state. Shared by the initial chat POST and the approve/reject +// continuations (which are themselves SSE streams resuming the same turn). +func (s *chatSession) drive(resp *http.Response) (turnResult, error) { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return turnResult{}, checkResponse(resp) // reads + closes the body + } + defer resp.Body.Close() + + var res turnResult + var text strings.Builder + textStarted := false + interleaved := false + seenContent := false // suppress leading whitespace some models emit after thinking + + err := consumeSSE(resp, func(event, data string) (bool, error) { + switch event { + case "conversation": + var d struct { + ID string `json:"id"` + } + _ = json.Unmarshal([]byte(data), &d) + if d.ID != "" { + s.convID = d.ID + } + case "message_start": + text.Reset() + textStarted = false + interleaved = false + seenContent = false + case "delta": + var d struct { + Text string `json:"text"` + } + _ = json.Unmarshal([]byte(data), &d) + txt := d.Text + if !seenContent { + txt = strings.TrimLeft(txt, " \t\r\n") + if txt == "" { + return false, nil + } + seenContent = true + } + fmt.Fprint(s.out, txt) + text.WriteString(txt) + textStarted = true + case "thinking": + if !isQuiet(s.cmd) { + var d struct { + Text string `json:"text"` + } + _ = json.Unmarshal([]byte(data), &d) + fmt.Fprint(s.errOut, s.styles.Muted.Render(d.Text)) + if textStarted { + interleaved = true + } + } + case "tool_call": + var d struct { + ID string `json:"id"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + RequiresApproval bool `json:"requires_approval"` + } + _ = json.Unmarshal([]byte(data), &d) + s.toolNames[d.ID] = d.Name + if d.RequiresApproval { + res.pending = append(res.pending, pendingTool{ID: d.ID, Name: d.Name, Args: d.Args}) + } else { + s.printToolLine(d.Name, "running…", toolRun) + } + case "tool_result": + var d struct { + ID string `json:"id"` + Status string `json:"status"` + } + _ = json.Unmarshal([]byte(data), &d) + name := s.toolNames[d.ID] + if name == "" { + name = "tool" + } + switch d.Status { + case "succeeded": + s.printToolLine(name, "✓", toolOK) + case "failed": + s.printToolLine(name, "✗ failed", toolFail) + case "rejected": + s.printToolLine(name, "✗ rejected", toolFail) + default: + s.printToolLine(name, d.Status, toolRun) + } + case "message_end": + s.finishMessage(text.String(), textStarted && !interleaved) + case "awaiting_approval": + res.awaiting = true + return true, nil + case "done": + var d struct { + Note string `json:"note"` + } + _ = json.Unmarshal([]byte(data), &d) + res.done = true + res.note = d.Note + return true, nil + case "error": + var d struct { + Message string `json:"message"` + } + _ = json.Unmarshal([]byte(data), &d) + res.errMsg = d.Message + return true, nil + } + return false, nil + }) + return res, err +} + +// finishMessage closes out one assistant message. In plain mode it just ends +// the streamed line. In TTY markdown mode — when the streamed block is safe to +// reprint (contiguous, fits the viewport) — it erases the raw stream and +// reprints it rendered with glamour. If anything is uncertain it leaves the raw +// text in place (still readable), never corrupting the screen. +func (s *chatSession) finishMessage(text string, safe bool) { + // A message with no text (e.g. one that only carried tool calls) leaves + // nothing on stdout — don't emit a stray blank line. + if strings.TrimSpace(text) == "" { + return + } + if !s.renderMarkdown() { + fmt.Fprintln(s.out) + return + } + rendered, err := s.md.Render(text) + if err != nil { + fmt.Fprintln(s.out) + return + } + w, h := s.termSize() + rows := screenRows(text, w) + if !safe || w <= 0 || h <= 0 || rows >= h { + fmt.Fprintln(s.out) + return + } + // Move to the top of the streamed block (cursor is on its last row) and + // clear to end of screen, then print the rendered version. + fmt.Fprintf(s.out, "\r\x1b[%dA\x1b[0J", rows-1) + fmt.Fprint(s.out, rendered) +} + +// screenRows counts the terminal rows a string occupies, accounting for soft +// wrapping at the given width (display width via lipgloss, so wide runes and +// any ANSI are measured correctly). +func screenRows(s string, width int) int { + if width <= 0 { + width = 80 + } + rows := 0 + for _, line := range strings.Split(s, "\n") { + w := lipgloss.Width(line) + if w == 0 { + rows++ + continue + } + rows += (w + width - 1) / width + } + if rows == 0 { + rows = 1 + } + return rows +} + +// ─── tool approval ────────────────────────────────────────────────────────── + +func (s *chatSession) handleApprovals(ctx context.Context, pending []pendingTool) (turnResult, error) { + work := append([]pendingTool(nil), pending...) + var last turnResult + for i := 0; i < len(work); i++ { + tc := work[i] + approve, err := s.decideApproval(tc) + if err != nil { + return last, err + } + verb := "reject" + if approve { + verb = "approve" + } + path := "/api/v1/ai/tool-calls/" + url.PathEscape(tc.ID) + "/" + verb + resp, err := s.client.Send(cli.Request{ + Method: http.MethodPost, Path: path, Accept: "text/event-stream", NoTimeout: true, Ctx: ctx, + }) + if err != nil { + return last, err + } + res, err := s.drive(resp) + if err != nil { + return res, err + } + last = res + if res.errMsg != "" { + return res, nil + } + // A continuation may surface fresh gated calls; fold them into the queue. + work = append(work, res.pending...) + if res.done { + return res, nil + } + } + return last, nil +} + +func (s *chatSession) decideApproval(tc pendingTool) (bool, error) { + if s.autoApprove { + s.printToolLine(tc.Name, "auto-approved", toolWarn) + return true, nil + } + if !term.IsTerminal(int(os.Stdin.Fd())) { + return false, fmt.Errorf("tool %q requires approval; re-run in an interactive terminal or pass --auto-approve", tc.Name) + } + summary := compactArgs(tc.Args) + fmt.Fprintf(s.errOut, "%s %s %s [y/N] ", + s.styles.Warn.Render("⚙ approve"), s.styles.Banner.Render(tc.Name), s.styles.Muted.Render(summary)) + line, _ := s.stdin.ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} + +func compactArgs(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var v any + if json.Unmarshal(raw, &v) != nil { + return "" + } + b, _ := json.Marshal(v) + str := string(b) + if str == "{}" || str == "null" { + return "" + } + if len(str) > 120 { + str = str[:117] + "..." + } + return str +} + +// ─── tool status lines ────────────────────────────────────────────────────── + +type toolKind int + +const ( + toolRun toolKind = iota + toolOK + toolFail + toolWarn +) + +func (s *chatSession) printToolLine(name, status string, kind toolKind) { + if isQuiet(s.cmd) { + return + } + var st string + switch kind { + case toolOK: + st = s.styles.Success.Render(status) + case toolFail: + st = s.styles.Error.Render(status) + case toolWarn: + st = s.styles.Warn.Render(status) + default: + st = s.styles.Muted.Render(status) + } + fmt.Fprintf(s.errOut, "%s %s %s\n", s.styles.Muted.Render("⚙"), name, st) +} + +func (s *chatSession) printError(msg string) { + fmt.Fprintln(s.errOut, s.styles.Error.Render("error: "+msg)) +} + +// ─── model / provider picker ──────────────────────────────────────────────── + +func (s *chatSession) pickModel() error { + provs, err := s.fetchProviders() + if err != nil { + return err + } + usable := provs[:0] + for _, p := range provs { + if p.Enabled && p.HasKey { + usable = append(usable, p) + } + } + if len(usable) == 0 { + return errors.New("no usable provider configured") + } + fmt.Fprintln(s.errOut) + for i, p := range usable { + label := p.Label + if label == "" { + label = p.Provider + } + fmt.Fprintf(s.errOut, " %s %s %s\n", + s.styles.Primary.Render(fmt.Sprintf("%d)", i+1)), label, s.styles.Muted.Render("("+p.Provider+")")) + } + prov, ok := s.pickIndex("provider", len(usable)) + if !ok { + return nil + } + chosen := usable[prov] + + models, listErr, err := s.fetchModels(chosen.ID) + if err != nil { + return err + } + var model string + if listErr != "" || len(models) == 0 { + if listErr != "" { + fmt.Fprintln(s.errOut, s.styles.Muted.Render("could not list models ("+listErr+")")) + } + fmt.Fprint(s.errOut, s.styles.Prompt.Render("model id ▸ ")) + line, _ := s.stdin.ReadString('\n') + model = strings.TrimSpace(line) + if model == "" { + return nil + } + } else { + fmt.Fprintln(s.errOut) + for i, m := range models { + label := m.Label + if label == "" { + label = m.ID + } + fmt.Fprintf(s.errOut, " %s %s\n", s.styles.Primary.Render(fmt.Sprintf("%d)", i+1)), label) + } + mi, ok := s.pickIndex("model", len(models)) + if !ok { + return nil + } + model = models[mi].ID + } + + if err := s.putSelection(chosen.ID, chosen.Provider, model, ""); err != nil { + return err + } + s.provider = chosen.Provider + s.model = model + if s.settings != nil { + s.settings.Provider = chosen.Provider + s.settings.Model = model + s.settings.ActiveProviderID = chosen.ID + } + fmt.Fprintln(s.errOut, s.styles.Muted.Render("using "+chosen.Provider+" / "+model)) + return nil +} + +func (s *chatSession) pickIndex(label string, n int) (int, bool) { + fmt.Fprint(s.errOut, s.styles.Prompt.Render(fmt.Sprintf("%s number ▸ ", label))) + line, _ := s.stdin.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return 0, false + } + var idx int + if _, err := fmt.Sscanf(line, "%d", &idx); err != nil || idx < 1 || idx > n { + s.printError("invalid selection") + return 0, false + } + return idx - 1, true +} + +// ─── AI API helpers ───────────────────────────────────────────────────────── + +func (s *chatSession) fetchSettings() (*aiSettings, error) { + resp, err := s.client.Get("/api/v1/ai/settings") + if err != nil { + return nil, err + } + if err := checkResponse(resp); err != nil { + return nil, err + } + var out struct { + Settings aiSettings `json:"settings"` + } + if err := decodeJSON(resp, &out); err != nil { + return nil, err + } + return &out.Settings, nil +} + +func (s *chatSession) fetchProviders() ([]providerView, error) { + resp, err := s.client.Get("/api/v1/ai/providers") + if err != nil { + return nil, err + } + if err := checkResponse(resp); err != nil { + return nil, err + } + var out struct { + Providers []providerView `json:"providers"` + } + if err := decodeJSON(resp, &out); err != nil { + return nil, err + } + return out.Providers, nil +} + +func (s *chatSession) fetchModels(providerID string) ([]modelInfo, string, error) { + resp, err := s.client.Get("/api/v1/ai/providers/" + url.PathEscape(providerID) + "/models") + if err != nil { + return nil, "", err + } + if err := checkResponse(resp); err != nil { + return nil, "", err + } + var out struct { + Models []modelInfo `json:"models"` + Error string `json:"error"` + } + if err := decodeJSON(resp, &out); err != nil { + return nil, "", err + } + return out.Models, out.Error, nil +} + +func (s *chatSession) putSelection(providerID, provider, model, thinking string) error { + body := map[string]string{} + if providerID != "" { + body["provider_id"] = providerID + } + if provider != "" { + body["provider"] = provider + } + if model != "" { + body["model"] = model + } + if thinking != "" { + body["thinking"] = thinking + } + resp, err := s.client.Put("/api/v1/ai/selection", body) + if err != nil { + return err + } + defer resp.Body.Close() + return checkResponse(resp) +} diff --git a/cli/commands/chat_test.go b/cli/commands/chat_test.go new file mode 100644 index 0000000..dd9a365 --- /dev/null +++ b/cli/commands/chat_test.go @@ -0,0 +1,197 @@ +package commands + +import ( + "bufio" + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Harsh-2002/Orva/cli/commands/theme" + cli "github.com/Harsh-2002/Orva/internal/client" + "github.com/spf13/cobra" +) + +// sseFrame writes one SSE event frame and flushes. +func sseFrame(w http.ResponseWriter, event, data string) { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +// newTestSession builds a chatSession wired to client with captured output and +// color disabled, suitable for driving canned SSE streams in tests. +func newTestSession(client *cli.Client) (*chatSession, *bytes.Buffer, *bytes.Buffer) { + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + s := &chatSession{ + cmd: &cobra.Command{}, + client: client, + styles: theme.New(false), + stdin: bufio.NewReader(strings.NewReader("")), + out: out, + errOut: errOut, + toolNames: map[string]string{}, + } + return s, out, errOut +} + +// TestDriveSSE drives a complete chat stream and asserts the assistant text is +// accumulated to stdout and the conversation id is captured. +func TestDriveSSE(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + sseFrame(w, "conversation", `{"id":"c1"}`) + sseFrame(w, "message_start", `{"message_id":"m1","role":"assistant"}`) + sseFrame(w, "delta", `{"text":"Hello "}`) + fmt.Fprint(w, ": ping\n\n") // heartbeat — must be ignored + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + sseFrame(w, "delta", `{"text":"world"}`) + sseFrame(w, "message_end", `{"message_id":"m1"}`) + sseFrame(w, "done", `{"conversation_id":"c1"}`) + })) + defer srv.Close() + + s, out, _ := newTestSession(cli.NewClient(srv.URL, "k")) + resp, err := s.postChat(context.Background(), "hi") + if err != nil { + t.Fatalf("postChat: %v", err) + } + res, err := s.drive(resp) + if err != nil { + t.Fatalf("drive: %v", err) + } + if !res.done { + t.Errorf("expected done=true, got %+v", res) + } + if s.convID != "c1" { + t.Errorf("convID = %q, want c1", s.convID) + } + if got := out.String(); !strings.Contains(got, "Hello world") { + t.Errorf("stdout = %q, want it to contain %q", got, "Hello world") + } + if strings.Contains(out.String(), "\x1b") { + t.Errorf("stdout contains ANSI escapes with color disabled: %q", out.String()) + } +} + +// TestApprovalFailClosedNonTTY ensures a tool requiring approval, in a +// non-interactive context without --auto-approve, fails closed and never issues +// an approve/reject POST. +func TestApprovalFailClosedNonTTY(t *testing.T) { + var approveHits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/tool-calls/") { + approveHits++ + return + } + w.Header().Set("Content-Type", "text/event-stream") + sseFrame(w, "message_start", `{"message_id":"m1","role":"assistant"}`) + sseFrame(w, "message_end", `{"message_id":"m1"}`) + sseFrame(w, "tool_call", `{"id":"t1","call_id":"call_1","name":"delete_function","args":{"name":"x"},"requires_approval":true}`) + sseFrame(w, "awaiting_approval", `{"conversation_id":"c1"}`) + })) + defer srv.Close() + + s, _, _ := newTestSession(cli.NewClient(srv.URL, "k")) + resp, err := s.postChat(context.Background(), "delete x") + if err != nil { + t.Fatalf("postChat: %v", err) + } + res, err := s.drive(resp) + if err != nil { + t.Fatalf("drive: %v", err) + } + if !res.awaiting || len(res.pending) != 1 { + t.Fatalf("expected one pending approval, got awaiting=%v pending=%d", res.awaiting, len(res.pending)) + } + _, err = s.handleApprovals(context.Background(), res.pending) + if err == nil || !strings.Contains(err.Error(), "requires approval") { + t.Errorf("expected fail-closed approval error, got %v", err) + } + if approveHits != 0 { + t.Errorf("expected no approve/reject POST, got %d", approveHits) + } +} + +// TestApprovalAutoApprove confirms --auto-approve issues the approve POST and +// consumes the continuation stream to completion. +func TestApprovalAutoApprove(t *testing.T) { + var approvePath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/approve") { + approvePath = r.URL.Path + w.Header().Set("Content-Type", "text/event-stream") + sseFrame(w, "tool_result", `{"id":"t1","call_id":"call_1","status":"succeeded","result":{"ok":true}}`) + sseFrame(w, "done", `{"conversation_id":"c1"}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + sseFrame(w, "message_start", `{"message_id":"m1","role":"assistant"}`) + sseFrame(w, "message_end", `{"message_id":"m1"}`) + sseFrame(w, "tool_call", `{"id":"t1","call_id":"call_1","name":"create_function","args":{},"requires_approval":true}`) + sseFrame(w, "awaiting_approval", `{"conversation_id":"c1"}`) + })) + defer srv.Close() + + s, _, _ := newTestSession(cli.NewClient(srv.URL, "k")) + s.autoApprove = true + resp, err := s.postChat(context.Background(), "make a function") + if err != nil { + t.Fatalf("postChat: %v", err) + } + res, err := s.drive(resp) + if err != nil { + t.Fatalf("drive: %v", err) + } + res, err = s.handleApprovals(context.Background(), res.pending) + if err != nil { + t.Fatalf("handleApprovals: %v", err) + } + if !res.done { + t.Errorf("expected continuation done, got %+v", res) + } + if approvePath != "/api/v1/ai/tool-calls/t1/approve" { + t.Errorf("approve path = %q, want /api/v1/ai/tool-calls/t1/approve", approvePath) + } +} + +// TestChatThinkingValidation rejects an invalid --thinking value before any +// network call. +func TestChatThinkingValidation(t *testing.T) { + root := NewRoot() + root.SetArgs([]string{"chat", "--thinking", "bogus", "-p", "hi"}) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "invalid --thinking") { + t.Errorf("expected thinking validation error, got %v", err) + } +} + +// TestScreenRows checks the wrapping-aware row count used by the glamour +// re-render eraser. +func TestScreenRows(t *testing.T) { + cases := []struct { + text string + width int + want int + }{ + {"hello", 80, 1}, + {"a\nb", 80, 2}, + {"a\nb\n", 80, 3}, // trailing newline = one extra (empty) row + {strings.Repeat("x", 100), 40, 3}, // 100/40 -> 3 wrapped rows + {"", 80, 1}, + } + for _, c := range cases { + if got := screenRows(c.text, c.width); got != c.want { + t.Errorf("screenRows(%q,%d) = %d, want %d", c.text, c.width, got, c.want) + } + } +} diff --git a/cli/commands/commands_test.go b/cli/commands/commands_test.go index e08a9b4..8a9bdee 100644 --- a/cli/commands/commands_test.go +++ b/cli/commands/commands_test.go @@ -39,6 +39,8 @@ func TestCommandTree(t *testing.T) { {"login"}, {"completion"}, {"upgrade"}, + {"chat"}, + {"docs"}, } for _, p := range paths { cmd, _, err := root.Find(p) @@ -92,6 +94,14 @@ func TestRequiredFlagsPresent(t *testing.T) { {[]string{"pool", "set"}, "fn"}, {[]string{"diff"}, "from"}, {[]string{"diff"}, "to"}, + {[]string{"chat"}, "prompt"}, + {[]string{"chat"}, "provider"}, + {[]string{"chat"}, "model"}, + {[]string{"chat"}, "thinking"}, + {[]string{"chat"}, "conversation"}, + {[]string{"chat"}, "auto-approve"}, + {[]string{"chat"}, "raw"}, + {[]string{"docs"}, "raw"}, } root := NewRoot() for _, c := range cases { diff --git a/cli/commands/completions.go b/cli/commands/completions.go new file mode 100644 index 0000000..f47e2d5 --- /dev/null +++ b/cli/commands/completions.go @@ -0,0 +1,167 @@ +package commands + +import ( + "net/url" + "time" + + cli "github.com/Harsh-2002/Orva/internal/client" + "github.com/spf13/cobra" +) + +// completionClient returns a client with a short timeout so a tab-completion +// (`__complete`) can never block the user's shell on an unreachable endpoint +// (the normal 120s client timeout would be a terrible completion experience). +func completionClient(cmd *cobra.Command) (*cli.Client, bool) { + c, err := getClient(cmd) + if err != nil { + return nil, false + } + c.HTTP.Timeout = 2 * time.Second + return c, true +} + +// This file wires dynamic shell completion onto the command tree. Completion +// functions run in a short-lived `__complete` invocation of the binary, so they +// talk to the live instance (using the same config/flags as normal commands) +// to suggest real resource names — function names, runtimes, models — instead +// of only static subcommand names. +// +// Wiring happens in wireCompletions (called from RegisterClient) rather than in +// per-file init() funcs: that way every command's flags are already registered, +// so RegisterFlagCompletionFunc never races init ordering. + +// fixedCompletion returns a completion func that always offers the given static +// values (and disables file completion). Used for enum flags. +func fixedCompletion(values ...string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return values, cobra.ShellCompDirectiveNoFileComp + } +} + +// completeFunctionNames suggests existing function names for the FIRST +// positional argument only (so it doesn't mis-suggest names for a trailing +// deployment id). Best-effort: any failure yields no suggestions, never an error. +func completeFunctionNames(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + client, ok := completionClient(cmd) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resp, err := client.Get("/api/v1/functions?limit=10000") + if err != nil || checkResponse(resp) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var out struct { + Functions []struct { + Name string `json:"name"` + } `json:"functions"` + } + if decodeJSON(resp, &out) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names := make([]string, 0, len(out.Functions)) + for _, f := range out.Functions { + names = append(names, f.Name) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// completeRuntimes suggests the runtime ids the instance supports. +func completeRuntimes(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + client, ok := completionClient(cmd) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resp, err := client.Get("/api/v1/runtimes") + if err != nil || checkResponse(resp) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var out struct { + Runtimes []struct { + ID string `json:"id"` + } `json:"runtimes"` + } + if decodeJSON(resp, &out) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + ids := make([]string, 0, len(out.Runtimes)) + for _, r := range out.Runtimes { + ids = append(ids, r.ID) + } + return ids, cobra.ShellCompDirectiveNoFileComp +} + +// completeChatModels suggests model ids of the operator's active provider for +// `orva chat --model`. +func completeChatModels(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + client, ok := completionClient(cmd) + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + settingsResp, err := client.Get("/api/v1/ai/settings") + if err != nil || checkResponse(settingsResp) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var st struct { + Settings struct { + ActiveProviderID string `json:"active_provider_id"` + } `json:"settings"` + } + if decodeJSON(settingsResp, &st) != nil || st.Settings.ActiveProviderID == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resp, err := client.Get("/api/v1/ai/providers/" + url.PathEscape(st.Settings.ActiveProviderID) + "/models") + if err != nil || checkResponse(resp) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var out struct { + Models []struct { + ID string `json:"id"` + } `json:"models"` + } + if decodeJSON(resp, &out) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + ids := make([]string, 0, len(out.Models)) + for _, m := range out.Models { + ids = append(ids, m.ID) + } + return ids, cobra.ShellCompDirectiveNoFileComp +} + +// wireCompletions attaches dynamic completions to the command tree. Commands +// are resolved via root.Find so this stays decoupled from how each subcommand +// var is named/scoped. +func wireCompletions(root *cobra.Command) { + // First positional = function name. + for _, path := range [][]string{ + {"invoke"}, {"diff"}, {"rollback"}, {"logs"}, + {"functions", "get"}, {"functions", "delete"}, + {"deployments", "list"}, {"deployments", "get"}, {"deployments", "logs"}, + } { + if c, _, err := root.Find(path); err == nil && c.Name() == path[len(path)-1] { + c.ValidArgsFunction = completeFunctionNames + } + } + + // --fn flag = function name. + for _, path := range [][]string{ + {"pool", "get"}, {"pool", "set"}, {"cron", "create"}, {"jobs", "list"}, + } { + if c, _, err := root.Find(path); err == nil { + _ = c.RegisterFlagCompletionFunc("fn", completeFunctionNames) + } + } + + // Enum + resource flags. + if c, _, err := root.Find([]string{"deploy"}); err == nil { + _ = c.RegisterFlagCompletionFunc("runtime", completeRuntimes) + } + if c, _, err := root.Find([]string{"chat"}); err == nil { + _ = c.RegisterFlagCompletionFunc("model", completeChatModels) + } + // Global --output enum. + _ = root.RegisterFlagCompletionFunc("output", fixedCompletion("table", "json")) +} diff --git a/cli/commands/diff.go b/cli/commands/diff.go index 4d23785..7512974 100644 --- a/cli/commands/diff.go +++ b/cli/commands/diff.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/Harsh-2002/Orva/cli/commands/theme" cli "github.com/Harsh-2002/Orva/internal/client" "github.com/spf13/cobra" ) @@ -114,21 +115,14 @@ func runDiff(cmd *cobra.Command, args []string) error { _, err = os.Stdout.Write(body) return err } - return writeColorizedDiff(os.Stdout, string(body)) + return writeColorizedDiff(os.Stdout, string(body), styles(cmd)) } -// writeColorizedDiff applies ANSI coloring to the unified-diff bytes: +// writeColorizedDiff applies the Orva theme to the unified-diff bytes: // bold for the +++/--- file headers, cyan for @@ hunk headers, red for // removed lines, green for added lines. Untouched context lines pass // through plain so the output stays scannable. -func writeColorizedDiff(w io.Writer, body string) error { - const ( - reset = "\x1b[0m" - bold = "\x1b[1m" - red = "\x1b[31m" - green = "\x1b[32m" - cyan = "\x1b[36m" - ) +func writeColorizedDiff(w io.Writer, body string, s *theme.Styles) error { var sb strings.Builder for i, line := range strings.Split(body, "\n") { // strings.Split with a trailing newline yields a final empty @@ -138,21 +132,13 @@ func writeColorizedDiff(w io.Writer, body string) error { } switch { case strings.HasPrefix(line, "---"), strings.HasPrefix(line, "+++"): - sb.WriteString(bold) - sb.WriteString(line) - sb.WriteString(reset) + sb.WriteString(s.DiffMeta.Render(line)) case strings.HasPrefix(line, "@@"): - sb.WriteString(cyan) - sb.WriteString(line) - sb.WriteString(reset) + sb.WriteString(s.DiffHunk.Render(line)) case strings.HasPrefix(line, "-"): - sb.WriteString(red) - sb.WriteString(line) - sb.WriteString(reset) + sb.WriteString(s.DiffDel.Render(line)) case strings.HasPrefix(line, "+"): - sb.WriteString(green) - sb.WriteString(line) - sb.WriteString(reset) + sb.WriteString(s.DiffAdd.Render(line)) default: sb.WriteString(line) } diff --git a/cli/commands/docs.go b/cli/commands/docs.go new file mode 100644 index 0000000..fb0d761 --- /dev/null +++ b/cli/commands/docs.go @@ -0,0 +1,102 @@ +package commands + +import ( + _ "embed" + "io" + "os" + "os/exec" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// docsReference is the canonical Orva reference, embedded at compile time. It is +// the same single source (docs/reference.md) the get_orva_docs MCP tool serves; +// `make docs-embed` keeps this copy in sync. {{ORIGIN}} placeholders are +// substituted with the configured instance endpoint so URLs are pasteable. +// +//go:embed reference.md +var docsReference string + +const docsPlaceholderOrigin = "https://your-orva-instance.example.com" + +var docsCmd = &cobra.Command{ + Use: "docs", + GroupID: groupAI, + Short: "Show the Orva reference documentation", + Long: `Render the full Orva reference in the terminal. + +This is the same documentation the dashboard and the AI assistant use. On a +terminal it is rendered as styled markdown and paged through $PAGER (or less); +piped or with --raw it prints the raw markdown for grep/redirect. + + orva docs # rendered + paged + orva docs --raw # raw markdown + orva docs | grep -i webhook`, + Args: cobra.NoArgs, + RunE: runDocs, +} + +func init() { + docsCmd.Flags().Bool("raw", false, "print raw markdown without rendering or paging") +} + +func runDocs(cmd *cobra.Command, _ []string) error { + raw, _ := cmd.Flags().GetBool("raw") + + origin := docsPlaceholderOrigin + if c, err := getClient(cmd); err == nil { + if o := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/"); o != "" { + origin = o + } + } + md := strings.ReplaceAll(docsReference, "{{ORIGIN}}", origin) + + if raw || !stdoutIsTerminal() { + _, err := io.WriteString(os.Stdout, md) + return err + } + + width := 100 + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 && w < width { + width = w + } + r, err := glamour.NewTermRenderer(glamour.WithAutoStyle(), glamour.WithWordWrap(width)) + if err != nil { + _, err := io.WriteString(os.Stdout, md) + return err + } + rendered, err := r.Render(md) + if err != nil { + _, err := io.WriteString(os.Stdout, md) + return err + } + return pageOrPrint(rendered) +} + +// pageOrPrint streams content through the user's pager ($PAGER, else `less -R`) +// when one is available, falling back to a direct write. Best-effort: any +// failure to launch or run the pager degrades to printing the content. +func pageOrPrint(content string) error { + name, args := "less", []string{"-R"} + if p := strings.TrimSpace(os.Getenv("PAGER")); p != "" { + parts := strings.Fields(p) + name, args = parts[0], parts[1:] + } + path, err := exec.LookPath(name) + if err != nil { + _, werr := io.WriteString(os.Stdout, content) + return werr + } + c := exec.Command(path, args...) + c.Stdin = strings.NewReader(content) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + _, werr := io.WriteString(os.Stdout, content) + return werr + } + return nil +} diff --git a/cli/commands/output.go b/cli/commands/output.go index 4b481d0..9f3e58d 100644 --- a/cli/commands/output.go +++ b/cli/commands/output.go @@ -10,6 +10,7 @@ import ( "strings" "text/tabwriter" + "github.com/Harsh-2002/Orva/cli/commands/theme" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -74,6 +75,14 @@ func stdoutIsTerminal() bool { return term.IsTerminal(int(os.Stdout.Fd())) } +// styles resolves the Orva color palette for this command, honoring the same +// gate as colorEnabled (--no-color / NO_COLOR / JSON mode / non-TTY). Every +// command and the chat renderer style through the returned set so the look is +// consistent and color control stays in one place. +func styles(cmd *cobra.Command) *theme.Styles { + return theme.New(colorEnabled(cmd)) +} + // emitJSON writes v as indented JSON to stdout. This is the canonical machine // output path: clean, parseable, nothing else on stdout. func emitJSON(v any) error { @@ -121,8 +130,9 @@ func okf(cmd *cobra.Command, format string, a ...any) { return } msg := fmt.Sprintf(format, a...) - if colorEnabled(cmd) { - fmt.Fprintf(os.Stderr, "\x1b[32m✓\x1b[0m %s\n", msg) + s := styles(cmd) + if s.Enabled() { + fmt.Fprintf(os.Stderr, "%s %s\n", s.Success.Render("✓"), msg) } else { fmt.Fprintf(os.Stderr, "%s\n", msg) } diff --git a/cli/commands/reference.md b/cli/commands/reference.md new file mode 100644 index 0000000..9d1227d --- /dev/null +++ b/cli/commands/reference.md @@ -0,0 +1,1680 @@ +# Orva — Documentation + +> Everything you need to write, deploy, and operate functions on Orva. +> Generated from the in-app Docs page (`{{ORIGIN}}/web/docs`). + +## Table of contents + +1. [Handler contract](#handler-contract) +2. [Deploy & invoke](#deploy--invoke) +3. [Configuration reference](#configuration-reference) +4. [SDK from inside a function](#sdk-from-inside-a-function) +5. [Schedules](#schedules) +6. [Webhooks](#webhooks) +7. [MCP — Model Context Protocol](#mcp--model-context-protocol) +8. [System prompt for AI assistants](#system-prompt-for-ai-assistants) +9. [Tracing](#tracing) +10. [Errors & recovery](#errors--recovery) +11. [CLI](#cli) + +--- + +## Handler contract + +One exported function receives the inbound HTTP event and returns an +HTTP-shaped response. The adapter handles serialization and headers. + +### Handler — Python + +```python +def handler(event): + body = event.get("body") or {} + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": {"hello": body.get("name", "world")}, + } +``` + +### Handler — Node.js + +```js +exports.handler = async (event) => { + const body = event.body || {}; + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: { hello: body.name || 'world' }, + }; +}; +``` + +**Event shape:** `method`, `path`, `headers`, `query`, `body`. + +**Response:** `{ statusCode, headers, body }`. Non-string bodies are +JSON-encoded by the adapter. + +**Runtime env:** env vars and secrets land in `process.env` (Node) / +`os.environ` (Python). + +| Runtime | ID | Entrypoint | Dependencies | +|---|---|---|---| +| Python 3.14 | `python314` | `handler.py` | `requirements.txt` | +| Python 3.13 | `python313` | `handler.py` | `requirements.txt` | +| Node.js 24 | `node24` | `handler.js` | `package.json` | +| Node.js 22 | `node22` | `handler.js` | `package.json` | + +--- + +## Deploy & invoke + +The dashboard handles day-to-day work; these calls are for CI and +automation. Builds run async — poll `/api/v1/deployments/` or +stream `/api/v1/deployments//stream` until `phase: done`. + +### 1. Create the function row + +```bash +curl -X POST {{ORIGIN}}/api/v1/functions \ + -H 'X-Orva-API-Key: ' \ + -H 'Content-Type: application/json' \ + -d '{"name":"hello","runtime":"python314","memory_mb":128,"cpus":0.5}' +``` + +### 2. Upload code + +```bash +tar czf code.tar.gz handler.py requirements.txt +curl -X POST {{ORIGIN}}/api/v1/functions//deploy \ + -H 'X-Orva-API-Key: ' \ + -F code=@code.tar.gz +``` + +### Invoke + +### Invoke — curl + +```bash +curl -X POST {{ORIGIN}}/fn/ \ + -H 'Content-Type: application/json' \ + -d '{"name": "Orva"}' +``` + +### Invoke — fetch + +```js +const res = await fetch('{{ORIGIN}}/fn/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Orva' }), +}); +console.log(await res.json()); +``` + +### Invoke — Python + +```python +import httpx + +r = httpx.post( + "{{ORIGIN}}/fn/", + json={"name": "Orva"}, +) +print(r.json()) +``` + +> **Custom routes:** attach a friendly path with `POST /api/v1/routes`, +> the `set_route` MCP tool, the `orva routes set` CLI, OR the +> dashboard's function settings → "Custom routes" section (it +> collision-checks against other functions before saving). +> Reserved prefixes: `/api/` `/fn/` `/mcp/` `/web/` `/_orva/`. + +--- + +## Configuration reference + +Everything below lives on the function record. Secrets are stored +encrypted and only decrypt into the worker environment at spawn time. + +| Field | Purpose | Behaviour | +|---|---|---| +| `description` | Intent | One-sentence summary of what the function does (e.g. "resize uploaded images to webp"). Surfaces in `list_functions`, the dashboard's function card and search, and channel-mode tool descriptions exposed to other agents. Required when creating via MCP; optional via REST/CLI for backwards compat. | +| `env_vars` | Plain config | Plaintext config stored on the function record. Use for feature flags and non-secret settings. | +| `/secrets` | Encrypted | AES-256-GCM at rest. Values decrypt only into the worker environment at spawn time. | +| `network_mode` | Egress control | none = isolated loopback. egress = outbound HTTPS allowed; firewall blocklist applies. The orva SDK (kv / invoke / jobs) reaches orvad over the bridge, so it requires `egress`. | +| `auth_mode` | Invoke gate | none = public. platform_key = require Orva API key. signed = require HMAC. | +| `rate_limit_per_min` | Per-IP throttle | Optional cap for public or webhook-facing functions. Exceeding it returns 429. | +| custom routes | Pretty URLs | Operator-defined `/path` or `/prefix/*` mappings to the function. Manage via `POST /api/v1/routes`, the `set_route` MCP tool, the `orva routes set` CLI, or the dashboard's function settings → "Custom routes" section (collision-checks against other functions). Optional. | + +### Set a secret + +```bash +curl -X POST {{ORIGIN}}/api/v1/functions//secrets \ + -H 'X-Orva-API-Key: ' \ + -H 'Content-Type: application/json' \ + -d '{"key":"DATABASE_URL","value":"postgres://..."}' +``` + +### Signed-invoke recipe (HMAC, opt-in) + +```bash +# generate signature +SECRET='your-shared-secret-stored-in-function-secrets' +TS=$(date +%s) +BODY='{"hello":"world"}' +SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}') + +curl -X POST {{ORIGIN}}/fn/ \ + -H "X-Orva-Timestamp: $TS" \ + -H "X-Orva-Signature: sha256=$SIG" \ + -H 'Content-Type: application/json' \ + -d "$BODY" +``` + +--- + +## SDK from inside a function + +The bundled `orva` module is a stdlib-only wrapper over Orva's loopback +API. It ships beside the runtime adapter — `require('orva')` (Node) and +`from orva import …` (Python) work without `npm install` / `pip +install`. The Node SDK ships TypeScript declarations (`orva.d.ts`); the +Python SDK ships a `py.typed` marker so IDEs surface full type hints. + +Surface, as of v0.6: + +- **`kv`** — `get` / `put` / `delete` / `list(cursor)` / `getMany` / `putMany` / `deleteMany` / `incr` / `cas`. Per-function namespace, optional TTL, single-RTT batch ops, atomic counter & CAS. +- **`invoke(name, payload, {timeoutMs})`** — synchronous F2F call with `{statusCode, headers, body}` envelope. 8-deep call cap. +- **`invokeStream(name, payload, {timeoutMs})`** — same, but yields `Uint8Array` chunks via `for await`. +- **`jobs.enqueue(name, payload, {idempotencyKey, maxAttempts, scheduledAt})`** — durable background queue with built-in dedup. +- **`crons.upsert(name, schedule, {payload, timezone, enabled})`** — declare a cron schedule from the function body itself. +- **`trace.span(name, fn, attrs?)`** — wrap a code block as a child span; durations land in the trace waterfall. +- **`log.{debug,info,warn,error}(msg, fields?)`** — structured logs surfaced in the dashboard Logs lane. +- **`context`** — frozen view of `functionId`, `executionId`, `traceId`, `spanId`, `callDepth`, `timeoutMs`, `memoryMb`, `sdkVersion`. +- **`secrets.get(name)`** — explicit accessor over the secret environment vars. +- **`webhook.parse(event)`** — extract source / verified / payload from an inbound-webhook event without re-parsing headers. +- **`__test_mode__(impl)`** — swap the transport for tests so handlers run without a live server. + +### KV — get/put with TTL + +### KV — Python + +```python +from orva import kv + +def handler(event): + # Store with optional TTL (seconds). 0 = no expiry. + kv.put("user:42", {"name": "Ada", "tier": "pro"}, ttl_seconds=3600) + + # Read; default returned if missing or expired. + user = kv.get("user:42", default=None) + + # List by prefix. + pages = kv.list(prefix="page:", limit=50) + + # Delete is idempotent. + kv.delete("user:42") + + return {"statusCode": 200, "body": str(user)} +``` + +### KV — Node.js + +```js +const { kv } = require('orva') + +exports.handler = async (event) => { + await kv.put('user:42', { name: 'Ada', tier: 'pro' }, { ttlSeconds: 3600 }) + + const user = await kv.get('user:42', null) + + const pages = await kv.list({ prefix: 'page:', limit: 50 }) + + await kv.delete('user:42') + + return { statusCode: 200, body: JSON.stringify(user) } +} +``` + +> Browse / inspect / edit / delete / set keys without leaving the +> dashboard at `/web/functions//kv`. REST mirror at +> `GET/PUT/DELETE /api/v1/functions//kv[/]`. MCP tools: +> `kv_list` / `kv_get` / `kv_put` / `kv_delete`. + +### Function-to-function — invoke() + +### F2F — Python + +```python +from orva import invoke, OrvaError + +def handler(event): + try: + # invoke() returns the downstream {statusCode, headers, body}. + # body is JSON-decoded when possible. + result = invoke("resize-image", {"url": event["body"]["url"]}) + return {"statusCode": 200, "body": result["body"]} + except OrvaError as e: + # 404 = function not found, 507 = call depth exceeded. + return {"statusCode": e.status or 502, "body": str(e)} +``` + +### F2F — Node.js + +```js +const { invoke, OrvaError } = require('orva') + +exports.handler = async (event) => { + try { + const result = await invoke('resize-image', { url: event.body.url }) + return { statusCode: 200, body: result.body } + } catch (e) { + if (e instanceof OrvaError) { + return { statusCode: e.status || 502, body: e.message } + } + throw e + } +} +``` + +### Background jobs — jobs.enqueue() + +### Jobs — Python + +```python +from orva import jobs + +def handler(event): + # Fire-and-forget. Returns the job id immediately; the function + # body runs later via the scheduler. max_attempts retries with + # exponential backoff on 5xx / exception. + job_id = jobs.enqueue( + "send-welcome-email", + {"to": event["body"]["email"]}, + max_attempts=3, + ) + return {"statusCode": 202, "body": job_id} +``` + +### Jobs — Node.js + +```js +const { jobs } = require('orva') + +exports.handler = async (event) => { + const jobId = await jobs.enqueue( + 'send-welcome-email', + { to: event.body.email }, + { maxAttempts: 3 } + ) + return { statusCode: 202, body: jobId } +} +``` + +> **Network mode:** the SDK reaches orvad over loopback through the +> host gateway, so the function needs `network_mode: "egress"`. On +> the default `"none"` the SDK throws `OrvaUnavailableError` with a +> clear hint. + +### KV — batch ops, atomic counter, compare-and-swap + +```python +from orva import kv, OrvaCASMismatch + +def handler(event): + # Hydrate a dashboard view in a single round trip. + users = kv.get_many(["user:1", "user:2", "user:3"]) + + # Atomic counter — safe under concurrent writers. + visits = kv.incr("visits", 1) + + # Compare-and-swap loop. Idiomatic safe read-modify-write. + while True: + cur = kv.get("counter", default=0) + try: + kv.cas("counter", cur, cur + 1) + break + except OrvaCASMismatch: + continue + + # Cursor-based pagination over the entire namespace. + cursor, walked = "", [] + while True: + page = kv.list(prefix="post:", limit=100, cursor=cursor) + walked.extend(page["keys"]) + cursor = page["next_cursor"] + if not cursor: + break + + return {"statusCode": 200, "body": {"visits": visits, "n": len(walked)}} +``` + +```js +const { kv, OrvaCASMismatch } = require('orva') + +exports.handler = async () => { + const users = await kv.getMany(['user:1', 'user:2', 'user:3']) + const visits = await kv.incr('visits') + + while (true) { + const cur = await kv.get('counter', 0) + try { + await kv.cas('counter', cur, cur + 1) + break + } catch (e) { + if (e instanceof OrvaCASMismatch) continue + throw e + } + } + + return { statusCode: 200, body: JSON.stringify({ visits }) } +} +``` + +### Jobs — idempotency + +```python +from orva import jobs + +def handler(event): + body = event["body"] or {} + # Same idempotency_key inside the window returns the existing job + # id instead of enqueuing again. Useful for webhook handlers that + # may be retried by the source. + res = jobs.enqueue( + "send-welcome-email", + {"to": body["email"]}, + idempotency_key=f"welcome:{body['email']}", + idempotency_window_seconds=3600, + ) + return {"statusCode": 202, "body": res} # {"id": "...", "replayed": false} +``` + +### Custom spans and structured logs + +```python +from orva import trace, log + +def handler(event): + log.info("incoming", fields={"path": event.get("path")}) + + with trace.span("parse"): + parsed = parse_body(event["body"]) + + with trace.span("transform", attributes={"rows": len(parsed)}): + result = transform(parsed) + + log.info("done", fields={"rows": len(result)}) + return {"statusCode": 200, "body": result} +``` + +```js +const { trace, log } = require('orva') + +exports.handler = async (event) => { + log.info('incoming', { path: event.path }) + + const parsed = await trace.span('parse', () => parseBody(event.body)) + const result = await trace.span('transform', () => transform(parsed), + { rows: parsed.length }) + + log.info('done', { rows: result.length }) + return { statusCode: 200, body: JSON.stringify(result) } +} +``` + +User spans and log entries render inline in the dashboard's trace +waterfall — `parse` and `transform` show as bars nested under the +function's main span, and the level-tagged log lines appear in the +Logs lane below. + +### Streaming F2F + +```js +const { invokeStream } = require('orva') + +exports.handler = async () => { + let total = 0 + for await (const chunk of invokeStream('big-report', {})) { + total += chunk.length + } + return { statusCode: 200, body: JSON.stringify({ bytes: total }) } +} +``` + +```python +from orva import invoke_stream + +def handler(event): + total = 0 + for chunk in invoke_stream("big-report", {}): + total += len(chunk) + return {"statusCode": 200, "body": {"bytes": total}} +``` + +### Cron-from-code, context, secrets, webhook helper + +```js +const { crons, context, secrets, webhook } = require('orva') + +exports.handler = async (event) => { + // Register a daily sweep — idempotent by (function, name). + await crons.upsert('daily-cleanup', '0 3 * * *', { + timezone: 'UTC', + payload: { source: 'self' }, + }) + + // Frozen view of the execution context. + if (context.callDepth >= 6) return { statusCode: 507, body: 'too deep' } + + // Explicit secret accessor (env-var passthrough today; reserved for + // per-secret access auditing later). + const token = secrets.get('STRIPE_KEY') + + // Parse an inbound-webhook event: HMAC was already verified + // server-side before this handler ran. + const w = webhook.parse(event) + if (!w.verified) return { statusCode: 401, body: 'unverified' } + + return { statusCode: 200, body: JSON.stringify({ src: w.source }) } +} +``` + +### Testing handlers without a server + +```js +const orva = require('orva') + +const memKV = new Map() +orva.__test_mode__({ + async request(method, path, opts) { + // Implement just enough of the wire protocol for your tests. + // Returns { status, body } the same shape the real transport does. + return { status: 200, body: '{}' } + }, +}) + +// then exercise your handler ... +``` + +--- + +## Schedules + +Fire any function on a cron expression. The scheduler runs as part of +the orvad process — no external service. Manage from the Schedules +page or via the API. Standard 5-field cron with the usual shorthands +(`@daily`, `@hourly`, `*/5 * * * *`). + +### Cron — curl + +> Create a daily-9am schedule for an existing function. payload is delivered as the invoke body. + +```bash +curl -X POST {{ORIGIN}}/api/v1/functions//cron \ + -H 'X-Orva-API-Key: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "cron_expr": "0 9 * * *", + "enabled": true, + "payload": {"task": "daily-summary"} + }' +``` + +### Cron — Toggle / edit + +> PUT accepts any subset of {cron_expr, enabled, payload}; omitted fields keep their previous value. next_run_at is recomputed on expr changes. + +```bash +# pause +curl -X PUT {{ORIGIN}}/api/v1/functions//cron/ \ + -H 'X-Orva-API-Key: ' \ + -H 'Content-Type: application/json' \ + -d '{"enabled": false}' + +# change schedule +curl -X PUT {{ORIGIN}}/api/v1/functions//cron/ \ + -H 'X-Orva-API-Key: ' \ + -H 'Content-Type: application/json' \ + -d '{"cron_expr": "*/15 * * * *"}' +``` + +### Cron — List & delete + +> GET /api/v1/cron lists every schedule across functions (with function_name JOIN); per-function uses the nested route. + +```bash +# all schedules +curl {{ORIGIN}}/api/v1/cron \ + -H 'X-Orva-API-Key: ' + +# delete one +curl -X DELETE {{ORIGIN}}/api/v1/functions//cron/ \ + -H 'X-Orva-API-Key: ' +``` + +> **Cron-fired headers:** every cron-triggered invocation arrives at +> the function with `x-orva-trigger: cron` and +> `x-orva-cron-id: cron_…` on the event headers, so user code can +> branch on origin. + +--- + +## Webhooks + +Operator-managed subscriptions for system events. Configure URLs from +the Webhooks page; Orva delivers signed POSTs to them when matching +events fire (deployments, function lifecycle, cron failures, job +outcomes). Subscriptions are global, not per-function. + +**Headers:** `X-Orva-Event`, `X-Orva-Delivery-Id`, +`X-Orva-Timestamp`, `X-Orva-Signature`. + +**Signature:** `sha256=hex(hmac(secret, ts.body))`. Same shape as +Stripe / signed-invoke. Receivers verify with the secret returned at +create time. + +**Retries:** 5 attempts, exponential backoff (≤ 1h). Receiver must 2xx +within 15s. + +| Event | When it fires | +|---|---| +| `deployment.succeeded` | A function build finished and the new version is active. | +| `deployment.failed` | A build failed or was rejected. | +| `function.created` | A new function row was created via POST /api/v1/functions. | +| `function.updated` | A function config was edited via PUT /api/v1/functions/{id} (status flips during a deploy do NOT fire this — see deployment.*). | +| `function.deleted` | A function was removed. | +| `execution.error` | An invocation finished with status=error or 5xx. | +| `cron.failed` | A scheduled run failed (bad expr, missing fn, dispatch error, or 5xx). | +| `job.succeeded` | A queued background job finished successfully. | +| `job.failed` | A queued job exhausted its retries (terminal failure). | + +### Verify a delivery + +### Verify — Python + +> Run on the receiver. Reject anything that fails verification — the signature ensures the request really came from this Orva instance. + +```python +import hmac, hashlib, time + +def verify(secret: str, ts: str, body: bytes, sig_header: str) -> bool: + if abs(time.time() - int(ts)) > 300: # 5-min skew window + return False + mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256) + expected = "sha256=" + mac.hexdigest() + return hmac.compare_digest(expected, sig_header) + +# In your Flask/FastAPI/etc. handler: +ts = request.headers["X-Orva-Timestamp"] +sig = request.headers["X-Orva-Signature"] +if not verify(WEBHOOK_SECRET, ts, request.get_data(), sig): + return "bad signature", 401 +``` + +### Verify — Node.js + +> Same shape as Stripe. Use timingSafeEqual to avoid sig-leak via timing. + +```js +const crypto = require('crypto') + +function verify(secret, ts, body, sigHeader) { + if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) return false + const mac = crypto.createHmac('sha256', secret) + mac.update(ts + '.') + mac.update(body) + const expected = 'sha256=' + mac.digest('hex') + if (expected.length !== sigHeader.length) return false + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader)) +} + +// In an express handler with raw body middleware: +app.post('/webhooks/orva', (req, res) => { + const ok = verify( + process.env.WEBHOOK_SECRET, + req.headers['x-orva-timestamp'], + req.body, // raw bytes — NOT parsed JSON + req.headers['x-orva-signature'] + ) + if (!ok) return res.status(401).send('bad signature') + res.sendStatus(200) +}) +``` + +--- + +## MCP — Model Context Protocol + +Same API surface the dashboard uses, exposed as 69 tools an agent can +call directly. API key permissions scope the available tool set. + +- **Endpoint:** `{{ORIGIN}}/mcp` +- **Auth header:** `Authorization: Bearer ` + (fallback: `X-Orva-API-Key: `) +- **Transport:** Streamable HTTP, MCP 2025-11-25. + +> **Strict input contract.** The MCP tool surface is required-by-default: optional fields are exceptions +> (pagination, list filters, true patches, opt-in TTLs). Notable required fields the agent must declare +> explicitly: on `create_function` — `name`, `description`, `runtime`, `entrypoint`, `timeout_ms`, +> `memory_mb`, `cpus`, `network_mode`, `auth_mode`; on `invoke_function` — `method` (no silent POST default); +> on `deploy_function_inline` — `wait` (true blocks until built, false returns queued); on `create_api_key` — +> `permissions` (least-privilege subset of `[invoke, read, write, admin]`) and `expires_in_days`. The schema +> rejects missing fields at the JSON-RPC layer, so agents see "missing properties" errors at the moment of +> the call rather than runtime surprises later. + +> **`invoke_function` body envelope.** The `body` field is a typed discriminator, not free-form JSON. Pick one shape: +> ```jsonc +> { "type": "json", "json": { "name": "World" } } // sent as application/json +> { "type": "string", "string": "raw text payload" } // sent verbatim, no Content-Type forced +> { "type": "empty" } // no body — for GET / DELETE / HEAD +> ``` +> Omit the `body` field entirely if you have no payload. The platform validates the type at the JSON-RPC layer; an unknown `type` is rejected with a clear error. + +> **`invoke_function` diagnostic hints.** When the handler crashes with a network-shaped error (`ENETUNREACH`, `ECONNREFUSED`, `fetch failed`, `OrvaUnavailableError`) AND the function's `network_mode` is `"none"`, the response carries an `orva_hint` field telling the agent exactly what to fix (`network_mode='egress'` via `update_function`). Always check this field before doing your own root-cause analysis on a network error. + +> **Fixture tools** (`list_fixtures`, `save_fixture`, `delete_fixture`, `test_function_with_fixture`) let you save Postman-style request envelopes per function and replay them — useful for debugging and regression checks. The `test_function_with_fixture` tool accepts a shallow-merge `override` object so a single fixture can be parameterised per call without mutating the saved row. + +> **Tool naming and metadata.** Every operator-mode tool name matches `^[a-z][a-z0-9_]{0,62}$` and ships with a human-readable `Title`, ≥80-char description, and honest annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). The same rules apply to channel-mode auto-generated tools — see `backend/internal/mcp/CHECKLIST.md` for the canonical 12-rule policy if you contribute new tools. + +> **Channel-mode tool input.** When a downstream agent calls a channel-bundled tool, the input shape is the same `ChannelInvokeInput` envelope (`method`, `path`, `headers`, `body` with the discriminator above, `timeout_ms`). The output mirrors `invoke_function` (`status_code`, `headers`, `body` string, `execution_id`, optional `stderr`, optional `orva_hint`). + +> **Deploy-time SDK warning.** `deploy_function_inline` scans the source for the in-sandbox `orva` SDK import. If present AND the function's `network_mode` is `"none"`, the deploy result includes a `warning` field — the SDK will fail at runtime because it talks to orvad over the bridge network. Switch to `network_mode='egress'` and the next invoke is a cold start with a working SDK. + +> Generate a token from the Docs page in the dashboard, then drop it +> into your client config (Claude Code, Claude Desktop, Cursor, Cline, +> Codex, Windsurf, ChatGPT, etc.). Either header works against the +> same API key store with identical permission gating. + +### Install snippets (primary clients) + +### MCP — Claude Code + +> Anthropic's `claude` CLI. Restart Claude Code afterwards; `/mcp` lists Orva's 70 operator-mode tools. + +```bash +claude mcp add --transport http --scope user orva {{ORIGIN}}/mcp --header "Authorization: Bearer " +``` + +### MCP — curl + +> Talk to MCP directly. Step 1 returns a session id (Mcp-Session-Id) that Step 2 references. + +```bash +curl -sD - -X POST {{ORIGIN}}/mcp \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' + +curl -sX POST {{ORIGIN}}/mcp \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'Mcp-Session-Id: ' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +### More clients (Cursor, VS Code, Codex CLI, OpenCode, Zed, Windsurf, ChatGPT) + +### MCP (extra) — Claude Desktop + +> Paste into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS), %APPDATA%\Claude\claude_desktop_config.json (Windows), or ~/.config/Claude/claude_desktop_config.json (Linux). Restart Claude Desktop. + +```json +{ + "mcpServers": { + "orva": { + "url": "{{ORIGIN}}/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +### MCP (extra) — Cursor + +> Open the link in your browser. Cursor pops an approval dialog and writes ~/.cursor/mcp.json. + +```bash +cursor://anysphere.cursor-deeplink/mcp/install?name=orva&config=eyJ1cmwiOiJodHRwOi8vbG9jYWxob3N0Ojg0NDMvbWNwIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIDxZT1VSX09SVkFfVE9LRU4+In19 +``` + +### MCP (extra) — VS Code + +> User-scoped install via the Copilot-MCP `code --add-mcp` flag. Pick "Workspace" at the prompt to write .vscode/mcp.json instead. + +```bash +code --add-mcp '{"name":"orva","type":"http","url":"{{ORIGIN}}/mcp","headers":{"Authorization":"Bearer "}}' +``` + +### MCP (extra) — Codex CLI + +> OpenAI's `codex` CLI. Writes to ~/.codex/config.toml. + +```bash +codex mcp add --transport streamable-http orva {{ORIGIN}}/mcp --header "Authorization: Bearer " +``` + +### MCP (extra) — OpenCode + +> Interactive add. Pick "Remote", paste {{ORIGIN}}/mcp, then add the header Authorization: Bearer . + +```bash +opencode mcp add +``` + +### MCP (extra) — Zed + +> Zed runs MCP as stdio subprocesses, so use the `mcp-remote` bridge. Paste under context_servers in ~/.config/zed/settings.json. Restart Zed. + +```json +{ + "context_servers": { + "orva": { + "source": "custom", + "command": "npx", + "args": [ + "-y", "mcp-remote", + "{{ORIGIN}}/mcp", + "--header", "Authorization:Bearer " + ] + } + } +} +``` + +### MCP (extra) — Windsurf + +> Paste into ~/.codeium/windsurf/mcp_config.json and reload Windsurf. + +```json +{ + "mcpServers": { + "orva": { + "serverUrl": "{{ORIGIN}}/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +### MCP (extra) — claude.ai web + +> UI-only flow. Settings → Connectors → Add custom connector. claude.ai opens an Orva login + consent popup, then issues an OAuth 2.1 token automatically — no token paste required. Refresh tokens rotate per OAuth 2.1 §4.3.1. + +```text +URL: {{ORIGIN}}/mcp +Auth: OAuth (auto-discovered) +``` + +### MCP (extra) — ChatGPT + +> UI-only flow. Settings → Apps & Connectors → Developer mode → Add new connector. ChatGPT discovers OIDC metadata, performs Dynamic Client Registration, and pops the Orva consent screen. No token paste required. + +```text +URL: {{ORIGIN}}/mcp +Auth: OAuth (auto-discovered) +``` + +### MCP — OAuth 2.1 vs static bearer + +`/mcp` accepts either a static API-key bearer (the existing path used +by Claude Code, Cursor, Cline, etc.) **or** an OAuth 2.1 access token. +The OAuth path exists for the browser-based "Add custom connector" +flows in the **claude.ai web UI** and **ChatGPT web UI** — they don't +expose a token-paste field, so static bearers can't be wired in by +hand. Orva ships its own OAuth authorization server so operators don't +need to run a second service. + +| Endpoint | RFC | Purpose | +|---|---|---| +| `GET /.well-known/oauth-protected-resource` | 9728 | Tells clients `/mcp` is OAuth-protected. | +| `GET /.well-known/oauth-authorization-server` | 8414 | Authorization Server Metadata. | +| `GET /.well-known/openid-configuration` | OIDC | Same metadata + OIDC fields (ChatGPT probes this). | +| `POST /register` | 7591 | Dynamic Client Registration. Per-IP rate-limited. | +| `GET/POST /oauth/authorize` | OAuth 2.1 | Server-rendered consent screen (uses session cookie). | +| `POST /oauth/token` | OAuth 2.1 | `authorization_code` + `refresh_token` grants. | +| `POST /oauth/revoke` | 7009 | Revoke an access or refresh token. | + +PKCE S256 is mandatory for every authorization request — "plain" is +forbidden per OAuth 2.1 §7.5.2. Access tokens live 1 hour; refresh +tokens live 30 days and rotate on use. Tokens are stored as SHA-256 +hashes (mirroring Orva's API-key posture). The consent screen is +gated by the Orva session cookie; if the user isn't logged in, +the request bounces through `/web/login` and back. + +DCR clients that don't request a specific scope get the full +`read invoke write admin` scope by default — without RBAC, the alternative +("OAuth tokens see fewer tools than the operator's own API key") just +makes browser connectors decoratively useless. The consent screen +collapses admin to a single bold "Full administrative control over your +Orva instance" line so the user knows exactly what they're granting. + +Granted apps appear in **Settings → Connected applications** with +authorized-at, last-used-at, and per-row Revoke. The matching REST +surface (used by the dashboard, also callable from the CLI): + +| Endpoint | Method | Purpose | +|---|---|---| +| `/api/v1/oauth/connected-apps` | GET | List active OAuth grants for the calling user | +| `/api/v1/oauth/connected-apps/{id}` | DELETE | Revoke a grant (idempotent — re-revoke returns 404) | +| `/api/v1/auth/sessions` | GET | List the calling user's active browser sessions (token returned as 16-char prefix only) | +| `/api/v1/auth/sessions/{prefix}` | DELETE | Revoke another session by prefix; calling session refuses unless `?allow_self=1` | + +### MCP — Agent channels (function bundles as tools) + +Agent channels expose N deployed functions as MCP tools to a third-party +agent — without giving that agent Orva-management authority. Each channel +has its own bearer token (`orva_chn_<32 hex>`); presenting it at `/mcp` +shows ONE MCP tool per bundled function (invoke-only) and nothing else. + +Use case: an agentic workflow needs `email-sender` and `summarize-text` +capabilities. Bundle those two functions into a "support-bot" channel, +hand the token to the workflow author. The workflow can call those two +functions and absolutely nothing else on the Orva instance. + +Tool names are converted from dash-separated to snake_case (`stripe-charge` +→ `stripe_charge`). Two functions whose names map to the same tool name +are rejected at create/update time. Channel tokens are accepted ONLY +on `/mcp`; presenting one at any `/api/v1/*` endpoint returns 401. + +**Auth headers** — channel tokens accept either header form on `/mcp`, +same as operator API keys: + +``` +Authorization: Bearer orva_chn_ # spec-standard, recommended +X-Orva-API-Key: orva_chn_ # parity with the REST API +``` + +Use whichever your MCP client supports. Most (Claude Code, Claude +Desktop, Cursor, ChatGPT custom connector, etc.) default to +`Authorization: Bearer`. + +Manage channels from the dashboard.s **Channels** page or via REST: + +| Endpoint | Method | Purpose | +|---|---|---| +| `/api/v1/channels` | GET | List channels | +| `/api/v1/channels` | POST | Create (token plaintext returned ONCE) | +| `/api/v1/channels/{id}` | GET | Detail with function set | +| `/api/v1/channels/{id}` | PATCH | Update name/description/expiry | +| `/api/v1/channels/{id}/functions` | PUT | Replace function set | +| `/api/v1/channels/{id}/rotate` | POST | Re-issue token (old one invalidated) | +| `/api/v1/channels/{id}` | DELETE | Cascade | + +Or via the CLI: `orva channels create --functions fn1,fn2`, +`orva channels list`, `orva channels rotate `, etc. + +### Hand-edited config files + +### MCP config — Cursor (global) + +> Paste into ~/.cursor/mcp.json, or .cursor/mcp.json in your project root for a per-workspace install. + +```json +{ + "mcpServers": { + "orva": { + "url": "{{ORIGIN}}/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +### MCP config — Cline + +> In VS Code: open Cline → MCP icon → Configure MCP Servers. Cline writes cline_mcp_settings.json. + +```json +{ + "mcpServers": { + "orva": { + "url": "{{ORIGIN}}/mcp", + "headers": { + "Authorization": "Bearer " + }, + "disabled": false + } + } +} +``` + +--- + +## System prompt for AI assistants + +Paste the prompt below into ChatGPT, Claude, Gemini, Cursor, Copilot, +or any other AI tool to teach it Orva's full surface — handler +contract, runtimes, sandbox limits, the in-sandbox `orva` SDK +(kv / invoke / jobs), cron triggers, system-event webhooks, auth +modes, and production patterns. The model then turns "describe what I +want" into a pasteable handler on the first try. + +```text +You are an Orva serverless-function expert. You write production-ready Python or Node handlers that follow Orva's contract exactly, use Orva's built-in primitives instead of inventing external infrastructure, and never produce framework boilerplate the platform doesn't need. + + +Orva is a self-hosted serverless platform — think Cloudflare Workers / Vercel Functions / AWS Lambda, but on the user's own box. Each function runs in a firecracker-style microsandbox with cold start ~200 ms and warm reuse for ~5 minutes. The platform ships HTTP routing, encrypted secrets, custom routes, scheduled triggers, durable background jobs, an in-sandbox KV store, function-to-function calls, system-event webhooks, per-function rate limiting, an outbound firewall, content-addressed deploys with rollback, and a 70-tool operator-mode MCP endpoint plus an auto-generated channel-mode endpoint that exposes one tool per bundled function to downstream agents. Everything below is the surface you write against. + + + +Pick exactly one — Orva has no Docker, no buildpacks, no per-function Python/Node version pinning beyond this: +- python314 (default) or python313 — entry: handler.py — deps: requirements.txt +- node24 (default) or node22 — entry: handler.js — deps: package.json +Older minor versions auto-migrate to the latest patch on next deploy. Native modules (psycopg2-binary, sharp, bcrypt, etc.) are supported via prebuilt wheels / npm prebuilts; if a dep needs a system library not present in the runtime image, the build will fail with a clear error. + + + +Export ONE function. It receives an event and returns an HTTP-shaped object. Sync or async are both valid; prefer async for I/O. + +Event: + event.method → "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS" | … + event.path → "/path?query=string" + event.headers → { "header-name": "value", ... } (lowercase keys, comma-joined dups) + event.query → { "key": "value", ... } (parsed from ?…; repeats become arrays) + event.body → string OR parsed JSON value, depending on Content-Type: + - application/json → parsed dict / array + - application/x-www-form-urlencoded → parsed dict + - multipart/form-data → { fields: {...}, files: [{name, filename, contentType, data: }] } + - everything else → raw string (or bytes for binary) + +Return: + { "statusCode": 200, + "headers": { "Content-Type": "application/json", ... }, + "body": } + +Non-string bodies are JSON-encoded by the adapter. To return binary (image, PDF), set Content-Type to the right MIME and return base64 in body with header { "x-orva-base64": "1" }. + +Other accepted handler styles (use the default unless the user asks): +- AWS Lambda: handler(event, context) +- Vercel/Express: handler(req, res) (Node only — call res.status(...).json(...)) +- GCP Functions: main(request) (Python — request is Flask-like) +- Cloudflare Worker: export default { fetch(req, env, ctx) { ... } } + + + +Plaintext env vars and encrypted secrets arrive at runtime through the same API: +- Python: os.environ["MY_KEY"] +- Node: process.env.MY_KEY +Set them from the editor's Settings modal or via: + POST /api/v1/functions//secrets { "key": "STRIPE_KEY", "value": "...", "encrypted": true } +Secrets are stored encrypted at rest, decrypted only into the worker environment at spawn time. NEVER log secret values. NEVER return them in a response. NEVER hardcode them in handler.py / handler.js. + + + +Every function has the `orva` module pre-imported — zero install, zero config. The SDK reaches orvad over the bridge network using HTTP, so the function MUST be created with `network_mode: "egress"` (or updated to it later). With the default `network_mode: "none"`, every SDK call fails at runtime with `ENETUNREACH` / `OrvaUnavailableError` / `TypeError: fetch failed`. THREE primitives: + +## orva.kv — per-function key/value store on SQLite +Per-function namespace; keys never collide across functions. Optional TTL in seconds (sweep every 5 min AND filtered at read time, so stale reads are impossible). Values JSON-serialised; cap 64 KB per value. Keys cap 256 chars. Use for: caches, idempotency keys, rate-limit counters, light session state, feature flags, last-seen markers. NOT a primary database, NOT a queue, NOT for blob storage. + + Python: + from orva import kv + + kv.put("user:42", {"name": "Ada", "tier": "pro"}, ttl_seconds=3600) + user = kv.get("user:42", default=None) # → dict, or None + pages = kv.list(prefix="page:", limit=50) # → [keys]; pass cursor= for pagination + kv.delete("user:42") # idempotent; no error if missing + + Node: + const { kv } = require('orva') + + await kv.put('user:42', { name: 'Ada', tier: 'pro' }, { ttlSeconds: 3600 }) + const user = await kv.get('user:42', null) + const pages = await kv.list({ prefix: 'page:', limit: 50 }) + await kv.delete('user:42') + +Common pattern — cache-aside: + hit = kv.get(cache_key) + if hit is not None: return hit + result = expensive_call() + kv.put(cache_key, result, ttl_seconds=600) + return result + +Common pattern — idempotency: + if kv.get(f"req:{idempotency_key}"): return {"statusCode": 200, "body": "already processed"} + do_work() + +Operators can browse / edit / delete / set keys live from the dashboard +at /web/functions//kv (the "KV" button in the editor's action +bar) — useful for hand-fixing a stuck counter or seeding test data +without redeploying. The same surface is reachable via REST +(GET/PUT/DELETE /api/v1/functions//kv[/]) and via MCP tools. +Tell the user about this when their function uses kv state and they +might want to inspect it. + kv.put(f"req:{idempotency_key}", "1", ttl_seconds=86400) + +## orva.invoke — function-to-function calls (no HTTP, no auth) +Bypasses the proxy stack and dispatches via the warm pool. Faster than internal HTTP, no signing required. Recursion guard: max call depth 8. The callee's full {statusCode, headers, body} is returned; body is JSON-decoded when possible. + + Python: + from orva import invoke, OrvaError + + try: + res = invoke("resize-image", {"url": event["body"]["url"]}) + # res = {"statusCode": 200, "headers": {...}, "body": } + except OrvaError as e: + # e.status: 404 = function not found, 408 = timeout, + # 507 = call depth exceeded, 5xx = downstream error + return {"statusCode": e.status or 502, "body": {"error": str(e)}} + + Node: + const { invoke, OrvaError } = require('orva') + + try { + const res = await invoke('resize-image', { url: event.body.url }) + } catch (e) { + if (e instanceof OrvaError) { + return { statusCode: e.status || 502, body: { error: e.message } } + } + throw e + } + +## orva.jobs — durable background queue with retries +Fire-and-forget. Producer returns immediately; worker runs async on the same pool. Backed by SQLite; survives orvad restart. Failed jobs retry with exponential backoff (1m, 2m, 4m, 8m, …) up to max_attempts, then move to "failed" terminal state (visible on the Jobs page; emits a job.failed webhook). + + Python: + from orva import jobs + job_id = jobs.enqueue( + "send-welcome-email", + {"to": "user@x.com", "tpl": "welcome"}, + delay_seconds=10, # optional, default 0 + max_attempts=3, # optional, default 3 + ) + + Node: + const { jobs } = require('orva') + const jobId = await jobs.enqueue( + 'send-welcome-email', + { to: 'user@x.com', tpl: 'welcome' }, + { delaySeconds: 10, maxAttempts: 3 } + ) + +The worker function receives the payload as event.body (parsed dict). Job-fired invocations arrive with header x-orva-trigger: "job" and x-orva-job-id: "job_..." — branch on those when the same function handles both HTTP and queue work. + +Idempotency rule: jobs CAN run more than once on retry. Make worker handlers idempotent (check kv for a "done" marker keyed on payload, or use the job id). + + + +Wire any function to a cron expression from the Schedules page or: + POST /api/v1/functions//cron { "expression": "*/5 * * * *", "timezone": "UTC", "enabled": true } + +Standard 5-field cron with shorthands: @hourly, @daily, @weekly, @monthly, @yearly. Plus the usual */N, ranges (1-5), and lists (1,15,30). Timezone defaults to the orvad process timezone; pass an IANA name to override per schedule. + +Cron-fired invocations arrive with these event headers — branch on them for dry-run / real-run logic, or to tag log lines: + x-orva-trigger: "cron" + x-orva-cron-id: "cron_..." + +The scheduler is in-process (no external service), drift < 1s, survives restart, hot-reloads on edit. Failed cron runs emit a cron.failed webhook. + + + +The platform fires HMAC-signed POSTs to operator-configured URLs when system events happen. Subscribe from the Webhooks page or via API. Use them to plug Orva into Slack, Discord, pager systems, your ops dashboard, or another Orva function. Catalog as of v0.3.1 (9 events): + deployment.succeeded, deployment.failed + function.created, function.updated, function.deleted + execution.error (handler returned 5xx or threw) + cron.failed (scheduled trigger errored) + job.succeeded, job.failed +Subscribe to ["*"] to receive every event. + +When the user wants their function to RECEIVE Orva webhooks (typical: a function as the receiver), verify like Stripe does. Headers Orva sends: + X-Orva-Event: e.g. "deployment.failed" + X-Orva-Timestamp: + X-Orva-Signature: sha256=." + raw_body))> +Steps in the receiver: + 1. Reject if abs(now - ts) > 300 (5-min skew window) + 2. Recompute mac = HMAC-SHA256(secret, ts + "." + raw_body_bytes) + 3. Compare "sha256=" + hex(mac) to X-Orva-Signature in CONSTANT TIME (hmac.compare_digest in Python; crypto.timingSafeEqual in Node) + 4. Reject on mismatch with 401; otherwise process and return 2xx within 15s. +Failed deliveries (non-2xx, timeout, network) retry up to 5× with exponential backoff. + + + +- Defaults (configurable per function): 128 MB memory, 0.5 CPU, 30 s timeout, 6 MB max payload, max 10 MB total response. +- Filesystem: read-only EXCEPT /code (your code) and /tmp (writable, ephemeral, cleared between cold starts). +- NO subprocess execution (subprocess / child_process disabled). NO raw sockets. NO listening ports — the platform owns the HTTP server. +- Network is OFF by default — sandbox has only loopback (no DNS, no outbound TCP). The user must flip "Allow outbound network" in the editor's Settings modal to call external HTTPS APIs (Stripe, OpenAI, a remote DB). Tell the user to do this whenever your code makes outbound calls. +- orva.kv / orva.invoke / orva.jobs ALSO require egress — the SDK reaches orvad over the bridge network via HTTP, so a function with `network_mode: "none"` will see every SDK call fail with ENETUNREACH / OrvaUnavailableError. If the handler imports the orva module, set `network_mode: "egress"` at create time (or update later) — the editor's deploy step will warn you when the import meets `none`. +- When egress IS enabled, the operator can further restrict it via the firewall + DNS allowlist (Firewall page). Assume best-effort; handle failures. +- Concurrency: each warm worker handles one request at a time. The pool autoscales workers up to the function's max_concurrent setting. Don't rely on in-process module-level state surviving across requests beyond best-effort caching. + + + +Configure auth_mode on the function record (editor Settings modal or PUT /api/v1/functions/): +- "public" (default) — anyone with the URL can invoke. If the function needs user auth, verify a JWT IN the handler. +- "platform_key" — caller must send X-Orva-API-Key: OR Authorization: Bearer , OR be in the Orva session cookie. Use for server-to-server, CI deploys, internal dashboards, cron-triggered functions invoked from elsewhere. Mint keys from the API Keys page. +- "signed" — caller signs the request with HMAC-SHA256 over "." using ORVA_SIGNING_SECRET (a function secret). Headers: X-Orva-Timestamp, X-Orva-Signature: sha256=. ±5 min skew window. Use for partner integrations where you've shared a secret and want pure HTTP without OAuth. + +For end-user apps prefer in-handler JWT verification (Auth0, Clerk, Supabase, Firebase) — the platform stays out of the way. Pattern in Python: + from jwt import decode, InvalidTokenError + try: + claims = decode(token, JWKS, algorithms=["RS256"], audience=AUDIENCE) + except InvalidTokenError: + return {"statusCode": 401, "body": {"error": "invalid token"}} + +Per-function rate limiting (rpm + burst) is configurable on the function record; the platform replies 429 BEFORE spawning a worker when exceeded. Don't reimplement rate limiting in handler code unless you need a custom key (e.g., per-tenant); use orva.kv counters with TTL for that. + + + +The platform never injects CORS headers. The handler controls them. +- Answer OPTIONS before any auth check. +- Attach CORS headers to EVERY response, including 401 / 500. +- Allowlist origins; do not echo "*" with credentials. +- Set Access-Control-Allow-Headers explicitly (Content-Type, Authorization, X-Requested-With, …). +Pattern: + CORS = { + "Access-Control-Allow-Origin": "https://app.example.com", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "600", + } + if event["method"] == "OPTIONS": + return {"statusCode": 204, "headers": CORS, "body": ""} + # ... real handler ... + return {"statusCode": 200, "headers": {**CORS, "Content-Type": "application/json"}, "body": data} + + + +Default URL: /fn/ (the function id is a UUIDv7). To attach a friendly path (/api/payments, /webhooks/stripe, /v1/users/{id}), the operator configures a route via the dashboard or: + POST /api/v1/routes { "path": "/api/payments", "function_id": "" } +Path params with {name} are passed in event.path_params. Reserved prefixes (do NOT suggest these for custom routes): /api/, /fn/, /mcp/, /web/, /_orva/. + + + +Treat each handler as a tiny service. Apply these by default: + +1. Validate input early. Return 400 with {"error": "..."} for missing/typed-wrong fields. NEVER trust event.body without checking shape. +2. Structured logs. print(json.dumps({...})) in Python or console.log(JSON.stringify({...})) in Node — one JSON object per line, includes a level, a request id (use event.headers["x-orva-request-id"]), and any relevant ids. Logs land on the Activity page and on stdout. +3. Idempotency where it matters (POST / job workers / webhook receivers). Key on a client-supplied Idempotency-Key header or the job id; store a "done" marker in orva.kv with 24 h TTL. +4. Timeouts on outbound HTTPS. httpx default is no timeout — set timeout=10. node fetch default is also no timeout — pass an AbortSignal.timeout(10_000). 30 s sandbox cap means you get killed mid-request otherwise. +5. Catch broad, return narrow. try/except around your business logic; map to 400 / 401 / 404 / 502 / 500 with a short message. Don't leak stack traces in production responses (log them, return a request id). +6. Hot-path safety. Module-level work runs once per cold start and re-runs on warm timeout. Cache JWKS / config / heavy imports at module level. Don't open DB connections at import time if they can fail — lazy-init inside the handler with a simple cached singleton. +7. JSON everywhere unless asked. Default Content-Type: application/json. Use text/html only when serving a web page. + +Anti-patterns to avoid: +- Spawning subprocesses (blocked by the sandbox). +- Polling external services from inside a request handler — use a cron-triggered function or orva.jobs. +- Storing user data in /tmp expecting it to persist (it doesn't between cold starts). +- "Health check" endpoints that hit the database — Orva already monitors warm workers; a 200 GET / that returns immediately is enough. +- Reinventing rate limiting / auth / signing schemes when the platform's built-ins fit. + + + +When the user describes a function, respond in this exact order. No preamble, no recap of the prompt. + +1. **Plan** — one short paragraph: what the function does, runtime chosen, deps, whether it needs egress, which orva.* surfaces (if any) it uses, suggested auth gate. +2. **Handler code** — a SINGLE ```python or ```javascript fenced block containing the FULL handler file (no partial snippets, no "..."). User pastes it as handler.py or handler.js verbatim. Include input validation, CORS if web-facing, structured error returns, and timeouts on outbound calls. +3. **Dependencies** (only if needed) — a SECOND fenced block labelled requirements.txt or package.json with pinned versions. +4. **Egress note** (only if the code makes outbound HTTPS calls) — exactly: "Enable 'Allow outbound network' in the editor's Settings modal." +5. **Auth recommendation** (only if the function should be private) — name the gate (platform_key, signed, or in-handler JWT) and one-line reason. +6. **Trigger note** (only if cron / job worker / webhook receiver) — explicit instruction so the user wires it up correctly in the dashboard. + +Do NOT generate Dockerfiles, infra config, or framework boilerplate (Express apps, FastAPI, Flask, Fastify, etc.). Orva runs the handler directly — there is no web server to bind to. + + + + +A POST endpoint that accepts a URL, fetches its og:image, caches the result by URL hash for 24 hours, and returns the image URL. + + + +**Plan.** Python 3.14 handler that accepts `{ "url": "..." }`, hashes the URL, checks orva.kv for a cached og_image (24-hour TTL), and on miss does an outbound HTTPS GET, parses the og:image meta tag, caches, and returns. Needs egress. Dep: `httpx`. No auth gate — receivers like this are typically public; add a JWT check if you'll expose it to anonymous web clients. + +```python +import hashlib, json, re, httpx +from orva import kv + +OG_RE = re.compile(rb']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)', re.I) + +async def handler(event): + if event["method"] != "POST": + return {"statusCode": 405, "headers": {"Content-Type": "application/json"}, + "body": {"error": "POST only"}} + + body = event.get("body") or {} + url = body.get("url") if isinstance(body, dict) else None + if not isinstance(url, str) or not url.startswith(("http://", "https://")): + return {"statusCode": 400, "headers": {"Content-Type": "application/json"}, + "body": {"error": "url required"}} + + cache_key = "og:" + hashlib.sha256(url.encode()).hexdigest()[:16] + cached = kv.get(cache_key) + if cached: + return {"statusCode": 200, "body": {"og_image": cached, "cached": True}} + + try: + async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as c: + r = await c.get(url, headers={"user-agent": "OrvaOGFetcher/1.0"}) + r.raise_for_status() + except httpx.HTTPError as e: + return {"statusCode": 502, "body": {"error": f"fetch failed: {e}"}} + + m = OG_RE.search(r.content) + if not m: + return {"statusCode": 404, "body": {"error": "no og:image found"}} + + og = m.group(1).decode("utf-8", "replace") + kv.put(cache_key, og, ttl_seconds=86400) + return {"statusCode": 200, "body": {"og_image": og, "cached": False}} +``` + +```txt +httpx==0.27.2 +``` + +Enable 'Allow outbound network' in the editor's Settings modal. + + + + + +Every night at 3 AM UTC, delete all KV keys that start with "session:" and are older than their TTL. Log how many we removed. + + + +**Plan.** Python 3.14 handler triggered by a cron schedule `0 3 * * *` UTC. Lists keys with prefix "session:" via orva.kv.list, page by page, and lets the TTL-based sweep handle expiration — but for keys we want gone NOW, we delete explicitly. No deps. No egress. Recommended auth: leave public; cron triggers don't go through the HTTP gate, but if anyone could hit it from outside, set platform_key. + +```python +import json +from orva import kv + +def handler(event): + is_cron = event["headers"].get("x-orva-trigger") == "cron" + deleted = 0 + cursor = None + + while True: + page = kv.list(prefix="session:", limit=200, cursor=cursor) + for key in page.get("keys", []): + kv.delete(key) + deleted += 1 + cursor = page.get("cursor") + if not cursor: + break + + print(json.dumps({ + "level": "info", + "msg": "session sweep done", + "deleted": deleted, + "trigger": "cron" if is_cron else "manual", + "request_id": event["headers"].get("x-orva-request-id"), + })) + + return {"statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": {"deleted": deleted}} +``` + +Wire it up: open Schedules → New Schedule → expression `0 3 * * *`, timezone UTC, function this one. Set auth_mode to platform_key on the function so the URL can't be triggered manually by random callers. + + + + + +A function that receives Stripe webhook events, verifies the signature, and on `checkout.session.completed` enqueues a background job to provision the user's account. + + + +**Plan.** Node 24 handler. Reads STRIPE_WEBHOOK_SECRET from env, verifies the X-Stripe-Signature header (HMAC-SHA256 over "."), and on `checkout.session.completed` enqueues an orva.jobs job. Returns 200 fast (Stripe times out at 10 s). Worker function "provision-account" runs in the background. No egress needed (we only RECEIVE; no outbound calls). No deps beyond the SDK and node:crypto. + +Set auth_mode to "public" — the HMAC IS the auth here, the platform_key gate would block Stripe. + +```javascript +const crypto = require('node:crypto') +const { jobs } = require('orva') + +const SECRET = process.env.STRIPE_WEBHOOK_SECRET + +function verifyStripe(rawBody, header) { + const parts = Object.fromEntries((header || '').split(',').map(p => p.split('='))) + const ts = parts.t + const sig = parts.v1 + if (!ts || !sig) return false + if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) return false + const mac = crypto.createHmac('sha256', SECRET).update(`${ts}.${rawBody}`).digest('hex') + if (mac.length !== sig.length) return false + return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig)) +} + +exports.handler = async (event) => { + if (event.method !== 'POST') { + return { statusCode: 405, body: { error: 'POST only' } } + } + // Stripe sends raw bytes. Orva passes the unparsed string through when + // Content-Type isn't application/json — make sure the route's content-type + // handling preserves the raw body. If event.body is an object (already + // parsed), JSON.stringify it back for verification. + const rawBody = typeof event.body === 'string' ? event.body : JSON.stringify(event.body) + const sigHeader = event.headers['stripe-signature'] + if (!verifyStripe(rawBody, sigHeader)) { + return { statusCode: 401, body: { error: 'bad signature' } } + } + + let payload + try { + payload = typeof event.body === 'string' ? JSON.parse(event.body) : event.body + } catch { + return { statusCode: 400, body: { error: 'invalid json' } } + } + + if (payload.type === 'checkout.session.completed') { + await jobs.enqueue('provision-account', { + stripe_event_id: payload.id, + session_id: payload.data.object.id, + customer: payload.data.object.customer, + }, { maxAttempts: 5 }) + } + + console.log(JSON.stringify({ + level: 'info', + msg: 'stripe webhook ok', + type: payload.type, + request_id: event.headers['x-orva-request-id'], + })) + + return { statusCode: 200, body: { received: true } } +} +``` + +Wire it up: +- Set secret STRIPE_WEBHOOK_SECRET on this function. +- Create a custom route POST /webhooks/stripe pointing at this function. +- Make sure a separate "provision-account" function exists; mark it idempotent on stripe_event_id (check orva.kv for a "provisioned:" marker before doing work). + + + +--- + +Now ask me what kind of function I want to build. When I describe it, return a complete, ready-to-paste handler file plus requirements.txt or package.json if any third-party deps are needed. Default to Python 3.14 unless I say otherwise. If my idea fits orva.kv (caching/state), orva.jobs (background work), orva.invoke (chaining functions), a cron schedule, or a webhook receiver, use those primitives instead of inventing external infrastructure. +``` + +--- + +## Tracing + +Every invocation chain is recorded as a causal trace — +**automatically, with zero changes to your function code**. HTTP +requests, F2F invokes, jobs, cron, inbound webhooks, and replays all +stitch into the same tree. The dashboard renders it as a waterfall at +`/traces`. + +Each execution row IS a span. Spans share a `trace_id`; child spans +point at their parent via `parent_span_id`. You don't instantiate +spans, you don't import a tracer — you just write your handler and +the platform plumbs IDs through every internal hop. + +### What user code sees + +Two env vars are stamped per invocation. Read them only if you want to +log the trace_id alongside your own messages — they're optional. + +```text +# Available inside every running function — refresh per-invocation: +ORVA_TRACE_ID=tr_3e39f6991c66f140577c6021da7dd13b # one per causal chain +ORVA_SPAN_ID=sp_4ceba57f6b1c982e # this execution + +# Python: os.environ["ORVA_TRACE_ID"] +# Node.js: process.env.ORVA_TRACE_ID +# Reading them is optional — the platform records the trace for you. +``` + +### Automatic propagation + +When a function calls another via the SDK, the trace context flows +through automatically. The called function becomes a child span of +the caller; both share the same `trace_id`. Job enqueues work the +same way: `orva.jobs.enqueue()` records the trace context on the job +row, so when the scheduler picks the job up later, the resulting +execution lands in the same trace as the function that enqueued it +— even if the gap is hours or days. + +```js +// Function A — calls B via the SDK. Trace context flows automatically. +const { invoke, jobs } = require('orva') + +module.exports.handler = async (event) => { + // F2F call — B becomes a child span under A. + const result = await invoke('send_email', { to: event.email }) + + // Job enqueue — when this job runs (now or in 6 hours), the resulting + // execution lands in the SAME trace as A. + await jobs.enqueue('audit_log', { action: 'sent', to: event.email }) + + return { statusCode: 200, body: 'ok' } +} +``` + +### Triggers + +Each span carries a `trigger` label so the UI can show how the chain +started. + +| Trigger | Meaning | +|---|---| +| `http` | Public HTTP request hit /fn//. Almost always a root span. | +| `f2f` | Another function called this one via orva.invoke(). Has a parent_span_id. | +| `job` | Background job runner picked up an enqueued job. Parent_span_id is whoever enqueued it. | +| `cron` | Scheduler fired a cron entry. Always a root span. | +| `inbound` | External webhook hit /webhook/{id}. Always a root span. | +| `replay` | Operator clicked Replay on a captured execution. Fresh trace, no link to original. | +| `mcp` | AI agent invoked the function via MCP invoke_function. Fresh trace. | + +### External correlation (W3C traceparent) + +Send a standard `traceparent` header on the inbound HTTP request and +Orva makes its trace a child of yours. The same trace_id is echoed +back as `X-Trace-Id` on every response, so external systems can +correlate without parsing bodies. + +```bash +# Send the W3C traceparent header — Orva will adopt it as the trace root. +curl -H "traceparent: 00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" \ + https://orva.example.com/fn/myfn/ + +# Response always echoes: +# X-Trace-Id: tr_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +``` + +### Outlier detection + +Each function maintains an in-memory rolling P95 baseline over its +last 100 successful warm executions. An invocation is flagged as an +outlier when it has at least 20 baseline samples AND its duration +exceeds **P95 × 2**. Cold starts and errors are excluded from the +baseline so a flapping function can't drag it down. The flag and +baseline P95 are stored on the execution row and rendered as an amber +flag icon next to the span. + +### Where to look + +- `/traces` — list of recent traces, filterable by function / status / outlier-only. +- `/traces/:id` — waterfall + per-span detail. Click a span to jump to its execution in the Invocations log. +- `GET /api/v1/traces/{id}` — full span tree as JSON. Pair with `list_traces` / `get_trace` MCP tools for AI agents. +- `GET /api/v1/functions/{id}/baseline` — current P95/P99/mean for a function. + +--- + +## Errors & recovery + +Every error response uses the same envelope so log scrapers and +retries can match on `code`. Deploys are content-addressed; rollback +retargets the active version pointer and refreshes warm workers. To +review what's about to change *before* rolling back, use the dashboard's +**Compare versions** view (link from each row in the Versions modal / +Deployments page) or `orva diff --from --to ` for +a unified-diff in the terminal. + +```json +{ + "error": { + "code": "VALIDATION", + "message": "name must be lowercase and dash-separated", + "request_id": "req_abc123" + } +} +``` + +| Code | When you see it | +|---|---| +| `VALIDATION` | Bad request body or path parameter. | +| `UNAUTHORIZED` | Missing or invalid API key / session cookie. | +| `NOT_FOUND` | Function, deployment, or secret doesn't exist. | +| `RATE_LIMITED` | Too many requests — check the Retry-After header. | +| `VERSION_GCD` | Rollback / diff target was garbage-collected. | +| `VERSION_NOT_FOUND` | Diff endpoint received an unknown deployment ID. | +| `INSUFFICIENT_DISK` | Host is below min_free_disk_mb. | + +--- + +## CLI + +`orva` is a single static binary that talks to a remote (or local) +Orva server over HTTPS. Same binary as the daemon — `orva serve` +starts a server, every other subcommand is a CLI client. Drop it on +operator laptops, CI runners, or anywhere bash runs. + +### Install + +- **Server included:** `curl -fsSL https://github.com/Harsh-2002/Orva/releases/latest/download/install.sh | sh` — daemon + nsjail + rootfs + CLI. +- **CLI only:** add `--cli-only` for a ~10 MB binary at `/usr/local/bin/orva` (no service, no rootfs). +- **Inside Docker:** the dashboard image ships the CLI at the same path; `docker exec orva orva system health` works out of the box (auto-authed via the bootstrap key the entrypoint writes to `~/.orva/config.yaml`). + +### Authenticate + +Generate a key from the Keys page in the dashboard, then: + +```bash +# 1. Generate an API key in the dashboard (Keys page) or via the API +# 2. Tell the CLI where to find your Orva and which key to use +orva login \ + --endpoint https://orva.example.com \ + --api-key orva_xxx_your_key_here + +# Writes ~/.orva/config.yaml. Subsequent commands need no flags. +orva system health # smoke test +``` + +### Command index + +| Command | Subcommands | Purpose | +|---|---|---| +| `orva login` | — | Save endpoint + API key to ~/.orva/config.yaml | +| `orva init` | — | Scaffold an orva.yaml in the current directory | +| `orva deploy` | [path] | Package a directory and deploy as a function | +| `orva invoke` | [name|id] | POST to /fn// and print the response | +| `orva logs` | [name|id] [--tail] | List recent executions; --tail follows live via SSE | +| `orva functions` | list / get / create / delete | CRUD for the function registry | +| `orva cron` | list / create / update / delete | Manage cron schedules attached to functions | +| `orva jobs` | list / enqueue / retry / delete | Background queue management | +| `orva kv` | list / get / put / delete | Browse a function’s key/value store | +| `orva secrets` | list / set / delete | AES-256-GCM secrets per function | +| `orva webhooks` | list / create / test / delete / inbound | System-event subscribers + inbound triggers | +| `orva routes` | list / set / delete | Custom URL → function path mappings | +| `orva keys` | list / create / revoke | Manage API keys | +| `orva activity` | [--tail] [--source web|api|...] | Paginated activity rows; live SSE with --tail | +| `orva system` | health / metrics / db-stats / vacuum | Server diagnostics | +| `orva setup` | [--skip-nsjail] [--skip-rootfs] | Install nsjail + rootfs on a bare host | +| `orva serve` | [--port N] | Run as the server daemon (not the CLI client) | +| `orva completion` | bash / zsh / fish / powershell | Emit shell completion script | + +### Common recipes + +#### Deploy + +```bash +# Init a project in cwd (creates orva.yaml + handler stub) +orva init + +# Deploy from a directory. Auto-detects handler.ts when tsconfig.json +# is present; else uses the runtime default (handler.js / handler.py). +orva deploy ./my-fn \ + --name resize-image \ + --runtime node24 + +# Override the entrypoint explicitly: +orva deploy ./my-fn --name api --runtime python314 --entrypoint app.py +``` + +#### Invoke + tail logs + +```bash +# Invoke a function by name or UUID id: +orva invoke resize-image --data '{"url":"https://example.com/cat.jpg"}' + +# Recent executions: +orva logs resize-image + +# Single execution, with stdout/stderr: +orva logs resize-image --exec-id exec_abc123 + +# Live tail — SSE stream, Ctrl-C to stop: +orva logs resize-image --tail +``` + +#### KV + +```bash +# List keys (optionally by prefix) +orva kv list resize-image +orva kv list resize-image --prefix user: + +# Read / write / delete +orva kv get resize-image cache:home +orva kv put resize-image cache:home '{"hits":42}' --ttl 3600 +orva kv delete resize-image cache:home +``` + +#### Secrets, cron, jobs, webhooks + +```bash +# Secrets — encrypted at rest, injected as env vars at spawn: +orva secrets set resize-image S3_BUCKET my-bucket +orva secrets list resize-image +orva secrets delete resize-image S3_BUCKET + +# Cron — fire a function on a schedule: +orva cron create --fn daily-report --expr '0 9 * * *' --tz Asia/Kolkata +orva cron list +orva cron update --enabled false # pause +orva cron delete + +# Jobs — fire-and-forget background queue: +orva jobs enqueue --fn send-email --data '{"to":"a@b.c"}' +orva jobs list --status pending +orva jobs retry +orva jobs delete + +# Outbound webhooks (system events): +orva webhooks create --url https://hooks.slack.com/... --events deployment.failed,job.failed +orva webhooks test + +# Inbound webhook triggers (external POST → function): +orva webhooks inbound create --fn order-handler --signature stripe +``` + +#### System health, metrics, vacuum + +```bash +orva system health # daemon up + DB ok +orva system metrics # JSON metrics snapshot +orva system db-stats # on-disk breakdown (orva.db, WAL, functions/) +orva system vacuum # rewrite SQLite to reclaim freelist pages + +orva activity # last 50 activity rows +orva activity --tail # live feed (Ctrl-C) +orva activity --source mcp --limit 200 # MCP-only, last 200 +``` + +### Shell completion + +```bash +orva completion bash | sudo tee /etc/bash_completion.d/orva +# or zsh / fish / powershell +``` diff --git a/cli/commands/root.go b/cli/commands/root.go index b348243..07b270a 100644 --- a/cli/commands/root.go +++ b/cli/commands/root.go @@ -47,20 +47,82 @@ func newRootEmpty() *cobra.Command { return root } +// Command help groups. Assigning each subcommand a GroupID clusters the flat +// command list in `orva --help` into readable sections instead of one long +// alphabetical wall. +const ( + groupFunctions = "functions" + groupData = "data" + groupEventing = "eventing" + groupNetwork = "network" + groupAI = "ai" + groupSystem = "system" +) + +// commandGroups maps each subcommand to its help group. Commands not listed +// here fall under cobra's default "Additional Commands" section. +func commandGroups() map[*cobra.Command]string { + return map[*cobra.Command]string{ + functionsCmd: groupFunctions, + deployCmd: groupFunctions, + deploymentsCmd: groupFunctions, + rollbackCmd: groupFunctions, + diffCmd: groupFunctions, + invokeCmd: groupFunctions, + logsCmd: groupFunctions, + fixturesCmd: groupFunctions, + tracesCmd: groupFunctions, + poolCmd: groupFunctions, + + kvCmd: groupData, + secretsCmd: groupData, + + cronCmd: groupEventing, + jobsCmd: groupEventing, + webhooksCmd: groupEventing, + channelsCmd: groupEventing, + + routesCmd: groupNetwork, + dnsCmd: groupNetwork, + firewallCmd: groupNetwork, + + chatCmd: groupAI, + docsCmd: groupAI, + + systemCmd: groupSystem, + activityCmd: groupSystem, + backupCmd: groupSystem, + keysCmd: groupSystem, + loginCmd: groupSystem, + completionCmd: groupSystem, + upgradeCmd: groupSystem, + } +} + // RegisterClient adds every client-side subcommand (the ones that talk // to a remote orvad over HTTP) to the supplied root. Both the slim CLI // binary and the server binary call this — single source of truth. func RegisterClient(root *cobra.Command) { + root.AddGroup( + &cobra.Group{ID: groupFunctions, Title: "Functions & deployments:"}, + &cobra.Group{ID: groupData, Title: "State (kv, secrets):"}, + &cobra.Group{ID: groupEventing, Title: "Eventing (cron, jobs, webhooks, channels):"}, + &cobra.Group{ID: groupNetwork, Title: "Network (routes, dns, firewall):"}, + &cobra.Group{ID: groupAI, Title: "AI:"}, + &cobra.Group{ID: groupSystem, Title: "System & maintenance:"}, + ) root.AddCommand( activityCmd, backupCmd, channelsCmd, + chatCmd, completionCmd, cronCmd, deployCmd, deploymentsCmd, diffCmd, dnsCmd, + docsCmd, firewallCmd, fixturesCmd, functionsCmd, @@ -79,4 +141,8 @@ func RegisterClient(root *cobra.Command) { upgradeCmd, webhooksCmd, ) + for cmd, group := range commandGroups() { + cmd.GroupID = group + } + wireCompletions(root) } diff --git a/cli/commands/theme/theme.go b/cli/commands/theme/theme.go new file mode 100644 index 0000000..2df5bae --- /dev/null +++ b/cli/commands/theme/theme.go @@ -0,0 +1,89 @@ +// Package theme is the single source of truth for the Orva CLI's color +// palette. It wraps charmbracelet/lipgloss, which auto-degrades truecolor → +// 256 → 16 colors and adapts to light/dark terminal backgrounds, so the same +// style definitions render sensibly on any OS and terminal. +// +// The palette mirrors the dashboard brand (frontend/src/style.css): a purple +// primary, plus the conventional success/danger/warning/info semantics. The +// primary uses an AdaptiveColor so the deep brand purple (#553F83, readable on +// a light background) flips to the lighter link violet (#8b7bd8) on the dark +// terminals most operators use. +// +// This package must NOT import the commands package — commands imports theme, +// never the reverse. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Styles is the resolved set of lipgloss styles the CLI renders through. When +// color is disabled every field is a plain pass-through style that emits no +// ANSI escapes, so callers never need to branch on color themselves — they +// always call s.Success.Render(...) and get the right thing. +type Styles struct { + Primary lipgloss.Style // brand accent (banners, prompts, headings) + Success lipgloss.Style // green — confirmations, ✓ + Error lipgloss.Style // red — failures, ✗ + Warn lipgloss.Style // amber — warnings + Info lipgloss.Style // sky — informational + Muted lipgloss.Style // dim — secondary text, thinking, hints + Banner lipgloss.Style // bold primary — the chat banner line + Prompt lipgloss.Style // the REPL input glyph + DiffAdd lipgloss.Style // green — added diff lines + DiffDel lipgloss.Style // red — removed diff lines + DiffHunk lipgloss.Style // cyan — @@ hunk headers + DiffMeta lipgloss.Style // bold — +++/--- file headers + + enabled bool +} + +// Enabled reports whether the styles emit color. +func (s *Styles) Enabled() bool { return s.enabled } + +// New builds the style set. When enabled is false every style is a no-op +// pass-through (no color, no bold), so the caller's existing color gate +// (--no-color / NO_COLOR / non-TTY) stays the single decision point. +func New(enabled bool) *Styles { + if !enabled { + plain := lipgloss.NewStyle() + return &Styles{ + Primary: plain, + Success: plain, + Error: plain, + Warn: plain, + Info: plain, + Muted: plain, + Banner: plain, + Prompt: plain, + DiffAdd: plain, + DiffDel: plain, + DiffHunk: plain, + DiffMeta: plain, + } + } + + var ( + primary = lipgloss.AdaptiveColor{Light: "#553F83", Dark: "#8b7bd8"} + success = lipgloss.Color("#22c55e") + danger = lipgloss.Color("#ef4444") + warn = lipgloss.Color("#eab308") + info = lipgloss.Color("#38bdf8") + muted = lipgloss.AdaptiveColor{Light: "#6b7280", Dark: "#9aa0aa"} + cyan = lipgloss.Color("#22d3ee") + ) + + return &Styles{ + Primary: lipgloss.NewStyle().Foreground(primary), + Success: lipgloss.NewStyle().Foreground(success), + Error: lipgloss.NewStyle().Foreground(danger), + Warn: lipgloss.NewStyle().Foreground(warn), + Info: lipgloss.NewStyle().Foreground(info), + Muted: lipgloss.NewStyle().Foreground(muted), + Banner: lipgloss.NewStyle().Foreground(primary).Bold(true), + Prompt: lipgloss.NewStyle().Foreground(primary).Bold(true), + DiffAdd: lipgloss.NewStyle().Foreground(success), + DiffDel: lipgloss.NewStyle().Foreground(danger), + DiffHunk: lipgloss.NewStyle().Foreground(cyan), + DiffMeta: lipgloss.NewStyle().Bold(true), + enabled: true, + } +} diff --git a/go.mod b/go.mod index 164f31b..43fcf76 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/Harsh-2002/Orva go 1.26.3 require ( + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/creativeprojects/go-selfupdate v1.5.2 github.com/google/jsonschema-go v0.4.2 github.com/google/uuid v1.6.0 @@ -28,6 +30,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect @@ -48,19 +51,28 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.25.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-github/v74 v74.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.8.0 // indirect @@ -69,13 +81,19 @@ require ( github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mark3labs/mcp-go v0.43.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -90,7 +108,10 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.68.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect golang.org/x/arch v0.23.0 // indirect diff --git a/go.sum b/go.sum index af155ff..c389ed9 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -60,6 +66,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= @@ -72,6 +84,22 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -84,6 +112,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= @@ -110,6 +140,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -140,6 +172,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= @@ -153,10 +187,19 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/maximhq/bifrost/core v1.5.15 h1:iXvDufyZd7willDmbVzFfzCdy/2NsKEI1S8Iv9LjCSM= github.com/maximhq/bifrost/core v1.5.15/go.mod h1:f6QHCvvzCQziMZ4JCNZP/GdZSeD50hww0vt7Uwl7lYY= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -167,6 +210,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -216,10 +263,16 @@ github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFn github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8= gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME= go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpFYYhH08z31NK1HmtTk= @@ -232,6 +285,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/internal/client/client.go b/internal/client/client.go index cd288a9..50aaab4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -41,6 +42,7 @@ type Request struct { Accept string // Accept header; defaults to application/json Headers map[string]string // extra headers (override the above) NoTimeout bool // skip the 120s client timeout (for streaming) + Ctx context.Context // optional request context (for cancellation) } // Send issues the described request and returns the live response. The @@ -56,7 +58,11 @@ func (c *Client) Send(r Request) (*http.Response, error) { method = http.MethodGet } - req, err := http.NewRequest(method, u, r.Body) + ctx := r.Ctx + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, method, u, r.Body) if err != nil { return nil, fmt.Errorf("create request: %w", err) } diff --git a/test/cli/build-matrix.sh b/test/cli/build-matrix.sh index 23ce027..63eaa19 100755 --- a/test/cli/build-matrix.sh +++ b/test/cli/build-matrix.sh @@ -65,18 +65,23 @@ for target in "${CLI_TARGETS[@]}"; do fi done -# Size sanity: the slim CLI should be under 20 MB stripped. If it +# Size sanity: the slim CLI should stay well under the ceiling stripped. If it # balloons past that, somebody pulled in a heavy server package. +# Ceiling is 28 MB: `orva chat`/`orva docs` render terminal markdown via +# charmbracelet/glamour, which pulls in chroma (syntax highlighting) — a +# deliberate ~8 MB add that puts the binaries in the ~18-20 MB range. The +# ceiling keeps headroom while still catching accidental server-package bloat. log "verifying binary sizes" -SIZE_LIMIT_BYTES=$((20 * 1024 * 1024)) +SIZE_LIMIT_MB=28 +SIZE_LIMIT_BYTES=$((SIZE_LIMIT_MB * 1024 * 1024)) for f in "$OUT"/orva-cli-*; do [[ -f "$f" ]] || continue size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f" 2>/dev/null || echo 0) if [[ "$size" -le "$SIZE_LIMIT_BYTES" ]]; then - ok "$(basename "$f"): $((size / 1024 / 1024)) MB (≤ 20 MB)" + ok "$(basename "$f"): $((size / 1024 / 1024)) MB (≤ ${SIZE_LIMIT_MB} MB)" PASS=$((PASS+1)) else - fail "$(basename "$f"): $((size / 1024 / 1024)) MB exceeds 20 MB ceiling" + fail "$(basename "$f"): $((size / 1024 / 1024)) MB exceeds ${SIZE_LIMIT_MB} MB ceiling" FAIL=$((FAIL+1)) fi done diff --git a/test/e2e/tests/test_cli.py b/test/e2e/tests/test_cli.py index a4119cd..080e475 100644 --- a/test/e2e/tests/test_cli.py +++ b/test/e2e/tests/test_cli.py @@ -131,6 +131,18 @@ def main(): rc, out, err = cli.run(*args) check(f"{label} -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:160]}") + section("orva docs (embedded reference) + completion") + rc, out, err = cli.run("docs", "--raw") + check("docs --raw -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:160]}") + check("docs --raw emits the reference markdown", "# Orva" in out[:400], + out.strip()[:160]) + # origin placeholder must be substituted (no raw {{ORIGIN}} left). + check("docs origin placeholder substituted", "{{ORIGIN}}" not in out, + "found unsubstituted {{ORIGIN}} in docs output") + rc, out, err = cli.run("completion", "bash") + check("completion bash -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:160]}") + check("completion script references orva", "orva" in out, out.strip()[:120]) + section("orva deploy + invoke (nsjail-dependent; skips if unavailable)") # Build a trivial node24 source dir and try a real deploy via the CLI. # If the build sandbox (nsjail) isn't available the deploy won't succeed; diff --git a/test/e2e/tests/test_cli_chat.py b/test/e2e/tests/test_cli_chat.py new file mode 100644 index 0000000..4f1edca --- /dev/null +++ b/test/e2e/tests/test_cli_chat.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""CLI ↔ AI: `orva chat` one-shot drives the real agentic loop via the mock LLM. + +The `chat` command talks to the same AI SSE backend the dashboard uses, over the +CLI's API key. Driven keyless through the mock provider (see harness), this covers +the one-shot (-p) path end to end: a plain reply, a read tool auto-running, a write +tool failing closed without approval, and the same write succeeding with +--auto-approve (which exercises the approve continuation stream). The interactive +REPL needs a TTY, so it isn't driven here; one-shot is the scriptable surface. +""" +import sys + +from harness import ( + OrvaClient, CLIRunner, start_mock, configure_mock_provider, + remove_mock_provider, section, check, summary, skip, +) + +TEST_FN = "e2e-cli-chat-fn" + +CREATE_ARGS = ( + 'CALL create_function {"name":"%s","description":"e2e cli chat",' + '"runtime":"node24","entrypoint":"handler.js","timeout_ms":30000,' + '"memory_mb":128,"cpus":1,"network_mode":"none","auth_mode":"none"}' +) % TEST_FN + + +def _functions(c): + # A fresh instance returns {"functions": null}; `.get(..., [])` would yield + # None (the key exists), so coalesce with `or []`. + return ((c.get("/api/v1/functions") or {}).get("functions")) or [] + + +def cleanup_fn(c): + try: + for fn in _functions(c): + if fn.get("name") == TEST_FN: + c.req("DELETE", f"/api/v1/functions/{fn['id']}", expect=(200, 204, 404)) + except Exception: + pass + + +def fn_exists(c): + return any(f.get("name") == TEST_FN for f in _functions(c)) + + +def main(): + c = OrvaClient() + if not c.key: + print("ORVA_API_KEY not set", file=sys.stderr) + return 2 + cli = CLIRunner() + if not cli.available(): + return skip("orva binary not built (set ORVA_BIN / run `make build`)") + + start_mock() + configure_mock_provider(c, approval="all_writes") + cleanup_fn(c) + try: + section("orva chat -p plain reply") + rc, out, err = cli.run("chat", "-p", "hello there", timeout=60) + check("plain chat -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:200]}") + check("plain chat prints a reply on stdout", out.strip() != "", out.strip()[:160]) + + section("orva chat -p read tool auto-runs (list_functions)") + rc, out, err = cli.run("chat", "-p", "CALL list_functions {}", timeout=60) + check("read-tool chat -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:200]}") + check("tool status line shown on stderr", "list_functions" in err, err.strip()[:200]) + + section("orva chat -p write tool fails closed without --auto-approve") + cleanup_fn(c) + rc, out, err = cli.run("chat", "-p", CREATE_ARGS, timeout=60) + check("write tool, no approval -> nonzero exit", rc != 0, f"rc={rc}") + blob = (out + err).lower() + check("error explains approval is required", + "requires approval" in blob or "auto-approve" in blob, (out + err).strip()[:200]) + check("function NOT created on fail-closed", not fn_exists(c)) + + section("orva chat -p --auto-approve runs the write tool") + rc, out, err = cli.run("chat", "-p", CREATE_ARGS, "--auto-approve", timeout=90) + check("auto-approve chat -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:200]}") + check("function created via --auto-approve", fn_exists(c)) + + section("orva chat --no-color emits no ANSI on stdout") + rc, out, err = cli.run("chat", "-p", "hello", "--no-color", timeout=60) + check("no-color chat -> exit 0", rc == 0, f"rc={rc} err={err.strip()[:200]}") + check("no ANSI escapes on stdout", "\x1b" not in out, repr(out[:80])) + finally: + cleanup_fn(c) + remove_mock_provider(c) + return summary() + + +if __name__ == "__main__": + sys.exit(main())