diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f1b5e..99bb573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to the Dojo CLI are documented here. This project adheres to [Keep a Changelog v1.1.0](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/). +## 2026-07-18 — Agent-Navigable & Headless (v1.1) + +The whole 35-command surface is now runnable non-interactively with a uniform, +machine-parseable contract — the CLI is a scriptable tool surface, not just a REPL. + +### Added + +- feat(headless): `dojo --one-shot "/cmd …"` now **runs the slash command** through the same dispatcher the REPL uses, instead of chatting the literal string to a model. All ~35 commands are reachable from a script, an agent, or CI +- feat(output): `--json` emits a stable **`{ok, command, data, error}` envelope** per command; streaming commands (`/run`, `/agent`, `/workflow`, `/pilot`) emit **NDJSON** lines as they go, then a terminal envelope. New `internal/output` seam; `printKV`-based commands yield structured `data` automatically +- feat(discovery): `dojo --commands` prints a machine-readable command catalog (name/aliases/short/usage), generated from the registry — the entry point for an agent discovering the surface (`--json` for the array form) +- feat(cli): exit-code fidelity — `0` ok · `1` runtime/gateway error · `2` usage (unknown command, bad config) · `130` cancel +- feat(tools): `/tools ` — inspect a single tool's input schema (unblocks `/apps call` from blind-guessing JSON) +- feat(run): `/run --plan ` — submit a hand-authored `ExecutionPlan` (JSON) to the orchestrator (no NL parsing, no chat fallback) +- feat(memory): `/trail edit ` — edit a memory entry (previously only add/rm/search); `/snapshot export [path]` — write a snapshot to a file +- feat(skill): CAS version pinning — `/skill get @` and `package-all [ver]` (previously always `latest`) +- feat(hooks): native `prompt` and `agent` hook types now execute against the Gateway (advisory-only — they run and log, only `command` hooks can veto); the Gateway client is injected into the hook runner +- feat(plugins): plugin-install integrity — source allowlist (DojoGenesis / TresPies-source by default; `--allow-any-source` to override) + optional `--sha256=` content pin; never bypassed by `--yes` + +### Changed + +- Commands that block on interactive confirmation (`/plugin install`, `/protocol install`, `/craft` prune/elevate/scaffold, `/code undo`) now **refuse cleanly** in headless mode instead of hanging on stdin (override with `--yolo`) +- `/warroom` and `/bloom` (pure TUIs) return an "unsupported in headless mode" envelope instead of launching a broken alt-screen; `/home` and `/pilot` fall back to their plain paths +- Unknown subcommands on `/agent`, `/apps`, `/garden`, `/hooks`, `/snapshot` now **error** instead of silently running `ls` + +### Fixed + +- `/run` (and streaming commands) headlessly seed a session id, and a mid-stream gateway error now propagates to `ok:false` / exit 1 instead of silently reporting success + +--- + ## 2026-07-17 — Hooks, Permissions, Delegation Routing, Guardrails, External Skills ### Added diff --git a/TODO.md b/TODO.md index 864d5e2..f349171 100644 --- a/TODO.md +++ b/TODO.md @@ -65,7 +65,7 @@ Client layer complete: Command layer (MVP): - [x] `/run ` -- routes through ChatStream; gateway handles orchestration internally (`cmd_workflow.go` lines 85-127) -- [ ] DAG status polling with live node display (not needed for MVP -- gateway handles internally) +- [x] DAG status polling with live node display -- shipped as `pollDAGUntilTerminal` (`cmd_workflow.go` line 263); the "not needed for MVP" note here was stale --- diff --git a/cmd/dojo/main.go b/cmd/dojo/main.go index 7310ef1..85b402d 100644 --- a/cmd/dojo/main.go +++ b/cmd/dojo/main.go @@ -2,6 +2,8 @@ package main import ( "context" + "encoding/json" + "errors" "flag" "fmt" "os" @@ -11,7 +13,9 @@ import ( "time" "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/commands" "github.com/DojoGenesis/cli/internal/config" + "github.com/DojoGenesis/cli/internal/plugins" "github.com/DojoGenesis/cli/internal/protocol" "github.com/DojoGenesis/cli/internal/repl" "github.com/DojoGenesis/cli/internal/state" @@ -31,9 +35,11 @@ func main() { flagCompletion = flag.String("completion", "", "Generate shell completions (bash|zsh|fish)") flagResume = flag.Bool("resume", false, "Resume the most recent session instead of starting fresh") flagSession = flag.String("session", "", "Resume a specific session ID instead of the most recent one (implies --resume; see /session ls)") - flagJSON = flag.Bool("json", false, "Output JSON lines in one-shot mode (for scripted pipelines)") + flagJSON = flag.Bool("json", false, "Output JSON in one-shot/--commands mode (envelope for /commands, JSON-lines for chat; for scripted pipelines)") + flagCommands = flag.Bool("commands", false, "Print the machine-readable command catalog and exit (respects --json)") flagPlain = flag.Bool("plain", false, "Plain text output (no ANSI colors, for piped/CI usage)") flagYolo = flag.Bool("yolo", false, "Skip all permission prompts for this run (dangerous; never persisted to settings.json)") + flagYes = flag.Bool("yes", false, "Assume 'yes' to confirmation prompts (lets confirm-gated commands run headless; narrower than --yolo)") ) flag.Parse() @@ -66,10 +72,12 @@ func main() { } } - // Load config + // Load config. A config/usage failure exits 2 (vs 1 for a runtime error) so + // a scripted caller can tell "you invoked me wrong" from "the run failed". cfg, err := config.Load() if err != nil { - fatalf("config error: %s", err) + fmt.Fprintf(os.Stderr, "dojo: config error: %s\n", err) + os.Exit(2) } // Flag overrides @@ -99,6 +107,14 @@ func main() { // Build gateway client gw := client.New(cfg.Gateway.URL, cfg.Gateway.Token, cfg.Gateway.Timeout) + // --commands: emit the machine-readable command catalog and exit. This is + // pure introspection (no gateway round-trip), the entry point for an agent + // discovering the surface before driving it. Generated from the Registry — + // one source of truth, not a hand-maintained list. + if *flagCommands { + os.Exit(runCommandCatalog(cfg, gw, *flagJSON)) + } + // One-shot mode: send a single message and exit. Ctrl+C cancels the single // turn and exits. // @@ -114,6 +130,15 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // A1 — agent-drivability. A one-shot message beginning with "/" is a + // slash command, not chat: route it through the same Registry.Dispatch + // the REPL uses, so all ~35 commands are reachable non-interactively. + // (Without this, "/health" was sent to a model as literal text.) Bare + // text still falls through to the chat stream below, unchanged. + if strings.HasPrefix(strings.TrimSpace(*flagOneShot), "/") { + os.Exit(runHeadlessCommand(ctx, cfg, gw, strings.TrimSpace(*flagOneShot), *flagJSON, *flagPlain || *flagNoColor, *flagYes)) + } + workspaceRoot, _ := os.Getwd() req := client.ChatRequest{ Message: *flagOneShot, @@ -304,6 +329,85 @@ complete -c dojo -f -a "/protocol" -d "KE harness discovery + install" } } +// newHeadlessRegistry builds a command Registry for a non-interactive run, +// mirroring how repl.New constructs it (scan plugins, then commands.New) but +// without any REPL/TUI/spirit state. The plugin scan is filesystem-only and +// best-effort — a missing plugins dir is not an error — so a scan failure is +// logged and execution continues, exactly as the REPL does. +func newHeadlessRegistry(cfg *config.Config, gw *client.Client) *commands.Registry { + plgs, err := plugins.Scan(cfg.Plugins.Path) + if err != nil { + fmt.Fprintf(os.Stderr, "dojo: plugin scan: %s\n", err) + } + // Seed a session id the way the chat one-shot path does (main's ChatRequest + // uses dojo-oneshot-). Commands that carry a session to the gateway — + // /run orchestration, /agent, /workflow — send *r.session; an empty string + // makes the gateway reject the request with "session_id is required". + session := fmt.Sprintf("dojo-headless-%d", time.Now().UnixNano()) + return commands.New(cfg, gw, plgs, &session) +} + +// runCommandCatalog prints the command catalog (JSON array, or a plain list) +// and returns the process exit code. +func runCommandCatalog(cfg *config.Config, gw *client.Client, asJSON bool) int { + cmds := newHeadlessRegistry(cfg, gw).Commands() + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(cmds); err != nil { + fmt.Fprintf(os.Stderr, "dojo: %s\n", err) + return 1 + } + return 0 + } + for _, c := range cmds { + fmt.Printf("/%-14s %s\n", c.Name, c.Short) + } + return 0 +} + +// runHeadlessCommand dispatches a single slash command non-interactively and +// returns the process exit code: 0 ok · 1 runtime/gateway error · 2 usage +// (unknown command). line includes the leading "/". In --json mode the result +// is a {ok, command, data, error} envelope on stdout; otherwise it runs like +// the REPL and renders any error to stderr. +func runHeadlessCommand(ctx context.Context, cfg *config.Config, gw *client.Client, line string, asJSON, plain, assumeYes bool) int { + reg := newHeadlessRegistry(cfg, gw) + reg.SetAssumeYes(assumeYes) + input := strings.TrimPrefix(line, "/") + + if asJSON { + env := reg.RunHeadless(ctx, input) + enc := json.NewEncoder(os.Stdout) + if err := enc.Encode(env); err != nil { + fmt.Fprintf(os.Stderr, "dojo: %s\n", err) + return 1 + } + if env.OK { + return 0 + } + if env.Error != nil && env.Error.Code == "unknown_command" { + return 2 + } + return 1 + } + + // Human/plain path: render like the REPL, error to stderr + exit code — but + // a one-shot dispatch is still non-interactive (no REPL loop, no TTY for an + // alt-screen program), so mark it headless. Commands then take their + // plain-text headless branch (e.g. /warroom, /bloom) and refuse stdin + // prompts instead of hanging or failing to open a TTY. + reg.SetHeadless(true) + if err := reg.Dispatch(ctx, input); err != nil { + fmt.Fprintf(os.Stderr, "dojo: %s\n", err) + if errors.Is(err, commands.ErrUnknownCommand) { + return 2 + } + return 1 + } + return 0 +} + func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "dojo: "+format+"\n", args...) os.Exit(1) diff --git a/docs/v1.1-scout-dossier.md b/docs/v1.1-scout-dossier.md new file mode 100644 index 0000000..2699d81 --- /dev/null +++ b/docs/v1.1-scout-dossier.md @@ -0,0 +1,166 @@ +# Dojo CLI — v1.1 High-Leverage Additions Dossier + +**Status:** DRAFT FOR REVIEW — soliciting feedback from other agents before the `v1.1.0` cut +**Date:** 2026-07-17 +**Baseline commit:** `3c195ae` (`main`, PR #3 "dojo-cli-advance" merge) +**Author:** scouting pass (3 parallel recon agents → main-thread synthesis) +**Scope:** what to add to the CLI *before* tagging the next release — not a spec, a scout. Every item carries a `file:line` anchor so a reviewer can verify the claim directly. + +--- + +## How to review this (for other agents) + +This document exists to be argued with. If you are a reviewing agent: + +1. **Verify, don't trust.** Every claim has a `file:line`. Open it. Several "gaps" in the prior planning docs turned out to be already-shipped (see the DAG reversal in §2). Assume this dossier contains at least one more. +2. **Score each addition** on the two axes used here — **leverage** (user/agent value) and **effort** (S ≈ <1h/~10-40 lines · M ≈ half-day, real design surface · L ≈ multi-day/new subsystem). Disagree with a rating? Say why. +3. **Leave your verdict inline** under §8 "Reviewer Notes" — append a dated block; do not rewrite the body. Use `KEEP / CUT / RESHAPE / NEEDS-SPEC` per item id (A1, B3, …). +4. **The release question is the real question:** which cluster earns the `v1.1.0` tag, and which slips to v1.2? See §7 for the current recommendation and argue with *that*. + +--- + +## 1. The tension + +The CLI shipped `v1.0.0` (Apr 12) and is functionally complete: 35 top-level commands, ~48 tested client methods, a clean package split, 118+ passing tests. Two overnight waves (fest 07-16, advance 07-17) added hooks, permissions, delegation routing, guardrails, and external-skill interop. It is *shippable* today. + +So the question is not "is it done" — it's **"what raises its leverage most before we badge a confident release?"** — and the answer bends on who the primary user is becoming. The evidence below says the CLI is being built *for agents to drive*, yet its entire command surface is currently unreachable to a non-interactive caller. That reframes the whole priority stack. + +Four candidate directions, in tension for limited pre-release time: + +| Theme | One-line thesis | Why it might win | Why it might not | +|---|---|---|---| +| **A. Agent-drivability** | Make the whole surface runnable non-interactively (`--one-shot "/cmd"` + `--json`) | Directly serves "other agents drive/review this CLI" — the stated goal | Touches the entry point + every renderer; not a one-liner | +| **B. Expose latent backend** | Surface capabilities the client already implements but no command reaches | Cheapest value/effort in the audit — backend exists + is tested | Individually small; a pile of conveniences, not a headline | +| **C. Finish extension seams** | Complete the half-built hook types + harden plugin install | Unlocks native LLM/agent hooks; closes a supply-chain hole | Real design surface (M–L); could slip the release | +| **D. Consistency/hygiene** | Kill the papercuts (silent-swallow subcommands, alias hijack, TUI-only commands) | Removes new-user/agent friction; mostly S | Unglamorous; easy to defer forever | + +**Recommendation (argue with it in §7):** lead the release with **A** (the leverage multiplier for every other command), plus a curated slice of **B**'s cheap wins and **C1** (the marquee seam completion), and run **D** as a batched hygiene sweep. Defer the desktop GUI (hibernated — do not touch). + +--- + +## 2. The two "packed-in" ideas — status + +The release was paused to "pack more features in," with two specific ideas named. Recon changed the picture on one of them. + +### Idea 1 — native `prompt` / `agent` hook types ✅ REAL GAP → **include as C1** + +`internal/hooks/runner.go:236-243` — of the four hook *types*, only `command` and `http` execute; `prompt` and `agent` are no-ops that warn once and `return nil`: + +```go +case "prompt", "agent": + r.warnUnimplemented(pluginName, h.Type) + return nil +``` + +The data model is already present and unused: `HookDef.Prompt` and `HookDef.Model` are parsed from `hooks.json` (`internal/plugins/scanner.go:45-46`) but never read. Today a plugin author who wants an LLM or sub-agent to fire on a lifecycle event must shell out to `curl`/a script via a `command` hook. See C1 for the implementation sketch (**effort M**). + +### Idea 2 — DAG live node-by-node status polling ❌ ALREADY SHIPPED → **reframed as B5 + a doc fix** + +This one is **not a gap.** `pollDAGUntilTerminal` (`internal/commands/cmd_workflow.go:263-283`) already loops `OrchestrationDAG`, re-renders every node's status each cycle (`printDAGNodes`, `:286-310`, ✓/→/✗/○ icons), sleeps 800ms, and exits on terminal state. It is wired into **both** `/run --dag` paths (`:166` NL-parse, `:192` template-match). It landed in commit `40b9ed8`, *after* commit `6114dd5` marked TODO.md "fully resolved" — so `TODO.md:68`'s unchecked "not needed for MVP" box is stale documentation, nothing more. + +- **Doc fix (trivial):** flip/remove `TODO.md:68` so a future reader doesn't re-derive a phantom gap. (This dossier is itself Exhibit A for how that stale line misleads.) +- **The real adjacent gap → B5:** there is no way to submit a *hand-authored* `ExecutionPlan` — the only plan producers are a heuristic NL parser and 4 hardcoded templates (`orchestration/dag.go:20-178`). The `ExecutionPlan` type is fully JSON-tagged and `Orchestrate` + the poller do 100% of the plumbing. A `/run --plan ` (or `/orchestrate `) path is the genuine orchestration addition hiding behind the phantom one. See B5. + +--- + +## 3. Theme A — Agent-Drivability (the headline) + +The load-bearing insight of this scout. If other agents are the audience, this theme is the release. + +| id | Addition | Leverage | Effort | Anchor | Notes | +|---|---|---|---|---|---| +| **A1** | **`--one-shot "/cmd …"` runs the command** instead of chatting the literal string | ★★★★★ | S–M | `cmd/dojo/main.go:113-172`; `internal/repl/repl.go:483,499` | Detect a leading `/` and route through `commands.Registry.Dispatch`; today the string goes straight to `ChatStream`. Unlocks **all 35 commands** for scripts/agents/CI in one change. | +| **A2** | **Universal `--json` output** across commands | ★★★★☆ | M | `internal/commands/cmd_system.go:100`; `cmd/dojo/main.go:174-178` | Only `/home` + `/pilot` take a bare `plain` token; nothing emits JSON. Add one shared flag in `Dispatch`, thread through each renderer. Without it, an agent gets colored human text it must scrape. | +| **A3** | **Exit-code fidelity + stop silently ignoring `--json`** | ★★★☆☆ | S | `cmd/dojo/main.go:174-178` | `--json` currently warns to stderr and continues (a scripted caller never notices). One-shot already does 0/1 correctly; decide on 2=config. Hard-error rather than warn-and-continue once A2 lands. | + +> **Why A leads:** A1 is a force multiplier on every other item in this dossier — a new `/trail edit` (B1) is far more valuable if an agent can invoke it headless. Ship A1+A2 and the CLI becomes a scriptable tool surface, not just a REPL. + +--- + +## 4. Theme B — Expose latent backend (cheap capability wins) + +Each of these is backed by a client method that **already exists and is unit-tested** — the work is a thin command wrapper. Ordered by value × cheapness. + +| id | Addition | Leverage | Effort | Backend (exists) | Gap anchor | +|---|---|---|---|---|---| +| **B1** | **`/trail edit `** | ★★★★☆ | S (~10 ln) | `UpdateMemory` `client.go:868` (PUT /v1/memory/:id) | **Zero call sites** outside tests. Fully built, tested, never invoked. Copy the `rm` case at `cmd_memory.go:64-76`. | +| **B2** | **`/tools ` parameter inspector** | ★★★★☆ | S | `Tool.Parameters` `client.go:195` (already fetched) | `toolsCmd` (`cmd_workflow.go:477-522`) discards the params and ignores its own `args`. Prints the input schema → unblocks `/apps call` from blind-guessing JSON. | +| **B5** | **`/run --plan ` / `/orchestrate `** (scripted ExecutionPlan) | ★★★★☆ | M | `Orchestrate` `client.go:695`, poller `cmd_workflow.go:263` | The real "Idea 2." `ExecutionPlan` is JSON-tagged; only NL/template producers exist today. `json.Unmarshal` a file → same two calls `/run --dag` uses. Serves CI where NL parsing is too fragile. | +| **B3** | **CAS versioning: `/skill get @`, `package-all [ver]`** | ★★★☆☆ | S–M | `CASResolveTag`/`CASCreateTag` take a `version` arg | Both call sites hardcode the literal `"latest"` (`cmd_skill.go:103,235`). Every push clobbers `latest`; pin/rollback is unreachable though the wire supports it. | +| **B6** | **`/snapshot export `** (write to file) | ★★★☆☆ | S | `ExportSnapshot` `client.go:951` | Today `fmt.Println(string(data))` only (`cmd_memory.go:151-160`) — unusable in the REPL (no shell redirection) and risks corrupting the terminal with raw bytes. Add an optional path arg → `os.WriteFile`. | +| **B4** | **`/skill push [name[:ver]]`** (single-blob CAS put) | ★★★☆☆ | M | `CASPutContent` `client.go:1198` + `CASCreateTag` | Reachable today only inside `package-all`'s walk for files named exactly `SKILL.md`. A one-file variant turns CAS into a usable ad-hoc "shareable content ref" primitive. | +| **B7** | **`/apps tools `** (tools for one app) | ★★☆☆☆ | S | `Tools()` grouping `cmd_workflow.go:492-503` | `/apps ls` shows a tool *count* (`App.Tools int`), never the names. Filter the existing namespace grouping to one app. Rides on B2's renderer. | +| **B8** | **Configurable memory-search limit** | ★★☆☆☆ | S | `SearchMemoriesRequest.Limit` `client.go:879` | `SearchMemories` hardcodes `Limit:20`; all three search surfaces are capped. Add a trailing numeric arg + widen the client signature. | + +--- + +## 5. Theme C — Finish the extension seams + +| id | Addition | Leverage | Effort | Anchor | Notes | +|---|---|---|---|---|---| +| **C1** | **Implement `prompt` + `agent` hook types** (Idea 1) | ★★★★☆ | M | `internal/hooks/runner.go:236-243` | `prompt` → buffered `ChatStream(h.Prompt, h.Model)`; `agent` → `CreateAgent`+`AgentChatStream`. Client plumbing already exists. **Design surface:** payload templating (share `promptFromPayload` `:267-273`), sync/async/blocking matrix, and whether an `agent` hook may veto like `command` can (`:112-118`). No new subsystem. | +| **C2** | **Plugin-install integrity verification** | ★★★★☆ | M–L | `internal/plugins/installer.go:44-58` | `/plugin install ` has no checksum/signature check — trust = eyeball-the-URL + y/N. Installed plugins run arbitrary `command`-type shell on lifecycle events. Add pinned-checksum or signed/allowlisted-source mode. **Security-flavored; may deserve its own ADR.** | + +--- + +## 6. Theme D — Consistency & hygiene sweep (batch these) + +Individually small, collectively the difference between "tool" and "polished tool." Most are S. + +| id | Fix | Effort | Anchor | +|---|---|---|---| +| **D1** | Unknown subcommand should **error**, not silently list. 5 commands swallow typos as `ls` (`/agent`, `/apps`, `/garden`, `/snapshot`, `/hooks`); the other ~9 grouped commands already reject them | S | `cmd_agent.go:232`, `cmd_apps.go:105`, `cmd_garden.go:106`, `cmd_memory.go:174`, `cmd_system.go:272` | +| **D2** | Drop the `memory` alias from `/garden` — it hijacks the intuitive `/memory` for the seed store | S | `cmd_garden.go:20` | +| **D3** | Resolve the seed/memory command sprawl: `/garden` vs `/craft seed`, `/trail` vs `/craft memory`, and **"seed search" is secretly `SearchMemories`** (3 different "search" impls). Pick one owner per resource; give seeds a real search | M | `cmd_garden.go:74`, `cmd_memory.go:83`, `cmd_craft.go:626-637` | +| **D4** | `/project switch @name` and `/project archive @name` fail where `/project status @name` works — route all three through `resolveProjectArg` | S | `project_cmds.go:204-210,295-303,630` | +| **D5** | Converge flag parsing on the scan-anywhere `extractModelFlag` helper (4 conventions exist today) | S | `cmd_code.go:187`, `cmd_plugin.go:34`, `cmd_protocol.go:283` | +| **D6** | `/bloom` and `/warroom` have **no non-TTY fallback** — they hard-launch an alt-screen TUI and break when piped; siblings `/home`/`/pilot` check for `plain` first | S | `cmd_bloom.go:17-43`, `cmd_warroom.go:17-45` | +| **D7** | `/craft seed harvest` is a self-admitted dead alias for `ls`, still advertised in 3 places — implement real curation or remove it | S | `cmd_craft.go:585-594`, `cmd_help.go:165`, `README.md:341` | +| **D8** | Generate shell completions from `Registry.cmds` — command list is hand-maintained in 3 places (`register()`, help `sections`, completions) with no drift check | M | `commands.go:192-229`, `cmd_help.go:30-170`, `main.go:212-300` | +| **D9** | Doc fix: flip/remove the stale `TODO.md:68` DAG-polling line (feature shipped — see §2) | trivial | `TODO.md:68` | + +--- + +## 7. Recommended v1.1 cut + +A coherent, defensible release that leads with agent-drivability and grafts the cheapest high-value wins. **This is the proposal to argue with.** + +**In the release:** + +| Priority | Items | Rationale | +|---|---|---| +| 1 | **A1, A2, A3** | The headline. Makes every command agent/CI-drivable — the multiplier on everything else. | +| 2 | **C1** (prompt/agent hooks) | Marquee seam completion; the real "Idea 1"; native LLM/agent hooks. | +| 3 | **B1, B2, B5** | Highest value×cheapness: memory edit, tool-schema inspector, scripted orchestration plans. | +| 4 | **B3** + **C2** | CAS pin/rollback; plugin-install integrity (security). C2 may split to its own ADR/PR. | +| 5 | **D-sweep: D1, D2, D6, D7, D9** | Batched papercut fixes + the doc correction. | + +**Deferred to v1.2+:** B4, B6, B7, B8 (nice-to-haves), D3 (needs a design decision on resource ownership), D4, D5, D8. + +**Explicitly out of scope:** the hibernated desktop GUI (`desktop/` — 7 exported bridge methods with zero frontend callers). `CLAUDE.md` forbids touching it; revival is gated on 3 unmet conditions. Do not build. + +**Sizing:** Priorities 1–3 are the true "v1.1" core (~1 focused build session with 2–3 parallel Sonnet tracks: A-track entry-point/renderer, B-track command wrappers, C1-track hooks). Priorities 4–5 can ride the same cut or a fast follow. + +--- + +## 8. Open questions / feedback wanted + +Explicit forks where a reviewing agent's judgment is wanted: + +1. **Is agent-drivability (Theme A) really the headline, or is it scope creep?** A1+A2 touch the entry point and every renderer. Counter-argument: ship the cheap B-wins now, do A as its own dedicated release. Which? +2. **`--json` shape.** Per-command bespoke JSON, or one envelope (`{command, ok, data, error}`)? The latter is far friendlier to an agent parsing across commands. Preference? +3. **Hook `prompt`/`agent` blocking semantics (C1).** Should an `agent`-type hook be able to *veto* the caller like `command` can today, or stay advisory? This is the one real design decision in C1. +4. **C2 (plugin integrity) — release blocker or fast-follow?** It's a live supply-chain hole (installed plugins run arbitrary shell), but proper signing is M–L. Ship a minimal checksum-pin now, or hold for a real ADR? +5. **D3 resource-ownership.** Who owns seeds vs memories — `/garden` + `/trail`, or fold both under `/craft`? The current three-way `search` overlap is a latent correctness trap. +6. **Version framing.** `v1.0.0` is already tagged, so mechanically this is `v1.1.0`. Is that the intended badge, or is a larger "this is the real 1.0-caliber release" story wanted (which would argue for including more of Theme A + C)? + +--- + +## Appendix — provenance + +Synthesized from three parallel read-only recon agents against `3c195ae`: +- **Command-surface & UX audit** — full 35-command inventory + 11 ranked UX gaps. +- **Capability-gap audit** — 48 client methods cross-referenced against every command call site; CAS/tools/snapshot/memory-update findings. +- **Extension-seam & stub audit** — hook-type maturity, the DAG-polling reversal, plugin-install gap. + +Every `file:line` in this dossier was reported by an agent that had the file open. Reviewers: re-verify before acting — the DAG reversal (§2) is proof the planning docs drift. diff --git a/internal/commands/activity_cmd.go b/internal/commands/activity_cmd.go index a5f5084..e2f8265 100644 --- a/internal/commands/activity_cmd.go +++ b/internal/commands/activity_cmd.go @@ -15,9 +15,9 @@ import ( // activityCmd returns the /activity command. // -// /activity — show last 10 entries -// /activity — show last n entries -// /activity clear — clear the activity log +// /activity — show last 10 entries +// /activity — show last n entries +// /activity clear — clear the activity log func (r *Registry) activityCmd() Command { return Command{ Name: "activity", @@ -30,6 +30,10 @@ func (r *Registry) activityCmd() Command { return fmt.Errorf("activity clear: %w", err) } fmt.Println() + if r.out.JSON() { + r.out.Data(map[string]any{"cleared": true}) + return nil + } gcolor.Bold.Print(gcolor.HEX("#7fb88c").Sprint(" Activity log cleared")) fmt.Println() fmt.Println() @@ -49,6 +53,10 @@ func (r *Registry) activityCmd() Command { } fmt.Println() + if r.out.JSON() { + r.out.Data(entries) + return nil + } gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Activity log (last %d)", n)) fmt.Println() fmt.Println() diff --git a/internal/commands/cmd_agent.go b/internal/commands/cmd_agent.go index 8db06c1..06081eb 100644 --- a/internal/commands/cmd_agent.go +++ b/internal/commands/cmd_agent.go @@ -229,7 +229,7 @@ func (r *Registry) agentCmd() Command { fmt.Println() return nil - default: // ls + case "ls": agents, err := r.gw.Agents(ctx) if err != nil { return fmt.Errorf("could not fetch agents: %w", err) @@ -272,6 +272,17 @@ func (r *Registry) agentCmd() Command { } fmt.Println() return nil + + default: + // D1 — an unrecognized subcommand used to fall silently + // into "ls" (matching Go's zero-value switch semantics for + // any unmatched string). That hid typos from the caller — + // especially costly for a headless/agent caller with no + // human watching the output to notice the mismatch. Bare + // "/agent" still lists (sub defaults to "ls" above, which + // the "ls" case handles), so only a real unrecognized + // token reaches here. + return fmt.Errorf("unknown subcommand %q — see /help", args[0]) } }, } @@ -341,8 +352,27 @@ func extractModelFlag(args []string) (rest []string, value string, present bool, return rest, value, present, nil } +// streamEvent is the NDJSON line shape emitted by chat-like streaming +// commands (/agent chat, /agent dispatch via streamAgentChat; /workflow and +// /run's chat fallback in cmd_workflow.go) in headless JSON mode. It +// deliberately mirrors internal/repl.RenderEvent's RenderJSON() output +// ({type, content, meta}) so a scripted caller parses one event shape +// whether it drove the REPL's --one-shot chat path or one of these +// command-specific streams. Content/Meta are omitempty because most event +// types carry only one or the other (e.g. tool_call has no content, text +// has no meta). +type streamEvent struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + // streamAgentChat sends a message to an agent and streams the SSE response. -// Thinking and tool-call events are rendered in dim colors; text is printed inline. +// Human mode: thinking and tool-call events are rendered in dim colors, text +// is printed inline. Headless JSON mode: each event becomes one streamEvent +// NDJSON line via r.out.Emit instead — the two are mutually exclusive per +// event (matching how printKV/colorStatus branch), not layered, so a JSON +// dispatch's captured-stdout fallback never fills up with duplicate ANSI text. func (r *Registry) streamAgentChat(ctx context.Context, agentID, message, model string) error { req := client.AgentChatRequest{ Message: message, @@ -351,37 +381,80 @@ func (r *Registry) streamAgentChat(ctx context.Context, agentID, message, model Model: model, } + var streamErrMsg string err := r.gw.AgentChatStream(ctx, agentID, req, func(chunk client.SSEChunk) { switch chunk.Event { + case "error": + // Mid-stream gateway error (rate limit, dead model, agent failure): + // capture it so streamAgentChat returns non-nil — headless envelope + // ok=false / exit 1 — mirroring /run in cmd_workflow.go. Still emit + // the NDJSON error line for a streaming JSON consumer. + streamErrMsg = agentNestedField(chunk.Data, "message") + if streamErrMsg == "" { + var m map[string]any + if json.Unmarshal([]byte(chunk.Data), &m) == nil { + if e, ok := m["error"].(string); ok { + streamErrMsg = e + } + } + } + if streamErrMsg == "" { + streamErrMsg = truncate(strings.TrimSpace(chunk.Data), 160) + } + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "error", Content: streamErrMsg}) + } + return case "thinking": // Gateway sends: event: thinking / data: {"type":"thinking","data":{"message":"..."},...} msg := agentNestedField(chunk.Data, "message") if msg == "" { msg = truncate(chunk.Data, 80) } + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "thinking", Content: msg}) + return + } fmt.Print(gcolor.HEX("#94a3b8").Sprint("\n [Thinking] " + msg)) case "tool_call", "tool_invoked": name := agentNestedField(chunk.Data, "tool") if name == "" { name = truncate(chunk.Data, 60) } + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "tool_call", Meta: map[string]string{"tool": name}}) + return + } fmt.Print(gcolor.HEX("#457b9d").Sprintf("\n [Tool: %s]", name)) case "tool_result", "tool_completed": - // absorbed into the response + // absorbed into the response — nothing rendered in either mode case "response_chunk": // Gateway sends: event: response_chunk / data: {"type":"response_chunk","data":{"content":"..."},...} if text := agentNestedField(chunk.Data, "content"); text != "" { + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "text", Content: text}) + return + } fmt.Print(text) } default: if text := agentExtractText(chunk.Data); text != "" { + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "text", Content: text}) + return + } fmt.Print(text) } } }) - fmt.Println() - fmt.Println() + if !r.out.JSON() { + fmt.Println() + fmt.Println() + } + if streamErrMsg != "" { + return fmt.Errorf("agent error: %s", streamErrMsg) + } return err } diff --git a/internal/commands/cmd_apps.go b/internal/commands/cmd_apps.go index 6266f16..9558117 100644 --- a/internal/commands/cmd_apps.go +++ b/internal/commands/cmd_apps.go @@ -103,10 +103,17 @@ func (r *Registry) appsCmd() Command { return nil default: // ls + if len(args) > 0 && sub != "ls" { + return fmt.Errorf("unknown subcommand %q — see /help", args[0]) + } apps, err := r.gw.ListApps(ctx) if err != nil { return fmt.Errorf("could not list apps: %w", err) } + if r.out.JSON() { + r.out.Data(apps) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" MCP Apps (%d)\n\n", len(apps))) if len(apps) == 0 { diff --git a/internal/commands/cmd_bloom.go b/internal/commands/cmd_bloom.go index 707b0b6..6331901 100644 --- a/internal/commands/cmd_bloom.go +++ b/internal/commands/cmd_bloom.go @@ -5,6 +5,7 @@ package commands import ( "context" "fmt" + "time" tea "github.com/charmbracelet/bubbletea" @@ -25,12 +26,52 @@ func (r *Registry) bloomCmd() Command { Usage: "/bloom", Short: "Watch your bonsai grow — animated zen garden", Run: func(ctx context.Context, args []string) error { + // /bloom has no plain-text fallback — it IS the fullscreen + // animated TUI. A headless JSON dispatch (or any non-interactive + // run) has no terminal to paint an alt-screen Bubbletea program + // into, so refuse cleanly instead of hanging or spraying + // screen-control bytes into a JSON stream. st, err := state.Load() if err != nil { return fmt.Errorf("loading state: %w", err) } - belt := spirit.CurrentBelt(st.Spirit.XP) + + // Headless: the animation has no terminal to paint into, but /bloom + // visualizes real spirit state — so emit that (belt, XP, streak, + // sessions, next belt, a koan) instead of refusing. No command is a + // dead end headless. + if r.out.JSON() || r.headless { + sp := st.Spirit + next, xpToNext := spirit.NextBelt(sp.XP) + koan := spirit.RandomKoan(belt.Rank, time.Now()) + if r.out.JSON() { + data := map[string]any{ + "belt": belt.Name, "belt_title": belt.Title, "rank": belt.Rank, + "xp": sp.XP, "streak_days": sp.StreakDays, "sessions": sp.TotalSessions, + "progress_pct": spirit.ProgressPercent(sp.XP), "koan": koan, + } + if next != nil { + data["next_belt"] = next.Name + data["xp_to_next"] = xpToNext + } + r.out.Data(data) + return nil + } + fmt.Println() + fmt.Printf(" %s %s\n\n", belt.Name, belt.Title) + printKV("xp", fmt.Sprintf("%d", sp.XP)) + if next != nil { + printKV("next belt", fmt.Sprintf("%s (%d XP to go)", next.Name, xpToNext)) + } + printKV("streak", fmt.Sprintf("%d days", sp.StreakDays)) + printKV("sessions", fmt.Sprintf("%d", sp.TotalSessions)) + fmt.Println() + fmt.Println(" " + koan) + fmt.Println() + return nil + } + model := tui.NewBloomModel(st.Spirit, belt) p := tea.NewProgram(model, tea.WithAltScreen()) diff --git a/internal/commands/cmd_code.go b/internal/commands/cmd_code.go index 2c2398b..6863349 100644 --- a/internal/commands/cmd_code.go +++ b/internal/commands/cmd_code.go @@ -78,13 +78,42 @@ func (r *Registry) codeCmd() Command { case "diff": return codeDiff(args[1:]) case "test": - return codeTest(args[1:]) + // pkgArgs recomputes the same default-package logic codeTest + // applies internally, so the JSON step label names the + // package actually run rather than a generic placeholder. + pkgArgs := args[1:] + err := codeTest(pkgArgs) + if r.out.JSON() && err == nil { + pkg := "./..." + if len(pkgArgs) > 0 { + pkg = pkgArgs[0] + } + r.out.Data(codeGateResult{ + Steps: []codeGateStep{{Step: "go test " + pkg + " -count=1 -race", Passed: true}}, + Passed: true, + }) + } + return err case "build": - return codeBuild() + err := codeBuild() + if r.out.JSON() && err == nil { + r.out.Data(codeGateResult{ + Steps: []codeGateStep{{Step: "go build ./...", Passed: true}}, + Passed: true, + }) + } + return err case "vet": - return codeVet() + err := codeVet() + if r.out.JSON() && err == nil { + r.out.Data(codeGateResult{ + Steps: []codeGateStep{{Step: "go vet ./...", Passed: true}}, + Passed: true, + }) + } + return err case "gate": - return codeGate() + return r.codeGate() case "undo": // Permission gate: "code.undo" reverts working-tree changes. // Allow (allowlisted / yolo) skips the in-function y/N prompt @@ -96,7 +125,15 @@ func (r *Registry) codeCmd() Command { if cwdErr != nil || cwd == "" { cwd = "the current directory" } - if !r.permissionGate("code.undo", "revert all unstaged changes in "+cwd, false) { + // Headless callers get a clean refusal instead of a silent + // decline — checked before the gate, same as /craft's + // permission-gated writes. + // /code undo reverts the whole working tree — stricter than the + // other confirm-gated commands: it needs --yolo, not just --yes. + if err := r.headlessRefuseStrict("undo file change"); err != nil { + return err + } + if !r.permissionGate("code.undo", "revert all unstaged changes in "+cwd, r.autoConfirmed()) { return nil } return codeUndoPreconfirmed() @@ -213,8 +250,28 @@ func codeVet() error { return runGoCmd("vet", "./...") } +// codeGateStep is one pass/fail record within a /code build|test|vet|gate +// JSON-mode result. +type codeGateStep struct { + Step string `json:"step"` + Passed bool `json:"passed"` +} + +// codeGateResult is the JSON-mode payload for /code build, /code test, +// /code vet, and /code gate — Steps holds one entry per gate that ran (one +// for a single-step command, up to three for the combined gate), in run +// order. Only ever emitted on overall success: a failing step's error already +// carries the failure detail via the envelope's `error` field, and +// Registry.RunHeadless's envelopeFor discards any Data set on a command that +// returns a non-nil error — so there is no partial/failed Steps entry to +// report here. +type codeGateResult struct { + Steps []codeGateStep `json:"steps"` + Passed bool `json:"passed"` +} + // codeGate runs the full build gate: build + test + vet. -func codeGate() error { +func (r *Registry) codeGate() error { fmt.Println() steps := []struct { label string @@ -225,6 +282,7 @@ func codeGate() error { {"go vet ./...", codeVet}, } + var passed []codeGateStep for _, step := range steps { gcolor.HEX("#e8b04a").Printf(" Running: %s\n", step.label) if err := step.fn(); err != nil { @@ -232,9 +290,13 @@ func codeGate() error { return err } gcolor.HEX("#22c55e").Printf(" PASSED: %s\n\n", step.label) + passed = append(passed, codeGateStep{Step: step.label, Passed: true}) } gcolor.Bold.Print(gcolor.HEX("#22c55e").Sprint(" All gates passed.\n\n")) + if r.out.JSON() { + r.out.Data(codeGateResult{Steps: passed, Passed: true}) + } return nil } diff --git a/internal/commands/cmd_craft.go b/internal/commands/cmd_craft.go index dc7a2b9..0a702af 100644 --- a/internal/commands/cmd_craft.go +++ b/internal/commands/cmd_craft.go @@ -84,6 +84,58 @@ func craftHelp() { fmt.Println() } +// ─── JSON result types (headless dispatch) ────────────────────────────────── +// +// One struct per /craft list/get subcommand that loops printing rows in +// Human mode — so a headless JSON dispatch (r.out.JSON()) gets a typed +// payload via r.out.Data(...) instead of the scraped-text fallback. See +// internal/output for the Envelope these become the `data` field of. + +// craftADRResult is the JSON-mode payload for a successfully written /craft adr. +type craftADRResult struct { + Number int `json:"number"` + Title string `json:"title"` + Path string `json:"path"` + Content string `json:"content"` +} + +// craftScoutResult is the JSON-mode payload for /craft scout. +type craftScoutResult struct { + Tension string `json:"tension"` + Content string `json:"content"` +} + +// craftMemoryListResult is the JSON-mode payload for /craft memory ls. +type craftMemoryListResult struct { + Count int `json:"count"` + Memories []client.Memory `json:"memories"` +} + +// craftMemorySearchResult is the JSON-mode payload for /craft memory search. +type craftMemorySearchResult struct { + Query string `json:"query"` + Count int `json:"count"` + Results []client.Memory `json:"results"` +} + +// craftSeedListResult is the JSON-mode payload shared by /craft seed ls, +// /craft seed harvest (currently an honestly-labelled alias of ls), and the +// no-args listing branch of /craft seed elevate — all three route through +// craftPrintSeedList, which sets this via the package-level curEmitter (see +// printKV in cmd_help.go for the same free-function pattern). +type craftSeedListResult struct { + Count int `json:"count"` + Seeds []client.Seed `json:"seeds"` +} + +// craftSeedSearchResult is the JSON-mode payload for /craft seed search. +type craftSeedSearchResult struct { + Query string `json:"query"` + Matched int `json:"matched"` + Total int `json:"total"` + Seeds []client.Seed `json:"seeds"` +} + // ─── /craft adr ────────────────────────────────────────────────────────────── func (r *Registry) craftADR(ctx context.Context, args []string) error { @@ -154,7 +206,14 @@ Be concise and precise. Focus on the decision, not the technology overview.`, ne // Permission gate "craft.adr" replaces the old ad-hoc y/N confirm at the // same site: the write is the risky part, so the generated ADR streams to // screen regardless and only the file write is gated (one prompt max). - if !r.permissionGate("craft.adr", "write "+path, false) { + // Headless callers get a clean refusal instead of a silent decline — + // checked before the gate so a scripted run never falls through to + // ConfirmInteractive's no-TTY false (which would otherwise report success + // having written nothing). + if err := r.headlessRefuse("write ADR file"); err != nil { + return err + } + if !r.permissionGate("craft.adr", "write "+path, r.autoConfirmed()) { return nil } @@ -165,6 +224,11 @@ Be concise and precise. Focus on the decision, not the technology overview.`, ne return fmt.Errorf("could not write ADR: %w", err) } + if r.out.JSON() { + r.out.Data(craftADRResult{Number: nextNum, Title: title, Path: path, Content: content}) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#7fb88c").Sprintf(" ADR written to %s", path)) fmt.Println() @@ -233,11 +297,14 @@ Be direct. Prefer action over exploration. Assume the operator has limited atten Stream: true, } - _, err := r.craftStream(ctx, systemPrompt, req) + content, err := r.craftStream(ctx, systemPrompt, req) fmt.Println() if err != nil { return fmt.Errorf("gateway error: %w", err) } + if r.out.JSON() { + r.out.Data(craftScoutResult{Tension: tension, Content: content}) + } return nil } @@ -380,9 +447,14 @@ Be specific. Do not suggest generic "add more documentation".` // at the same site. Only the --fix write path is gated — the analysis // above is read-only and prints for every mode, exactly like a run // without --fix. + // Headless callers get a clean refusal instead of a silent decline — + // checked before the gate, same as /craft adr. + if err := r.headlessRefuse("rewrite CLAUDE.md files"); err != nil { + return err + } if !r.permissionGate("craft.claude-md", fmt.Sprintf("rewrite %d CLAUDE.md file(s) in place under %s", len(relPaths), cwd), - false) { + r.autoConfirmed()) { return nil } @@ -416,6 +488,10 @@ func (r *Registry) craftMemory(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("could not fetch memories: %w", err) } + if r.out.JSON() { + r.out.Data(craftMemoryListResult{Count: len(memories), Memories: memories}) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Memory (%d)\n\n", len(memories))) if len(memories) == 0 { @@ -505,7 +581,13 @@ func (r *Registry) craftMemory(ctx context.Context, args []string) error { } fmt.Println() - if !craftConfirm(fmt.Sprintf("Delete these %d memory entries?", len(target))) { + // Headless callers get a clean refusal instead of blocking on stdin — + // checked before the y/N prompt (one prompt maximum: this replaces it + // rather than running alongside it). + if err := r.headlessRefuse("delete memory entries"); err != nil { + return err + } + if !r.autoConfirmed() && !craftConfirm(fmt.Sprintf("Delete these %d memory entries?", len(target))) { fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — nothing pruned.")) fmt.Println() return nil @@ -542,6 +624,10 @@ func (r *Registry) craftMemory(ctx context.Context, args []string) error { if err != nil { return fmt.Errorf("could not search memories: %w", err) } + if r.out.JSON() { + r.out.Data(craftMemorySearchResult{Query: query, Count: len(results), Results: results}) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Memory Search: %q (%d results)\n\n", query, len(results))) if len(results) == 0 { @@ -635,6 +721,10 @@ func (r *Registry) craftSeed(ctx context.Context, args []string) error { matched = append(matched, s) } } + if r.out.JSON() { + r.out.Data(craftSeedSearchResult{Query: query, Matched: len(matched), Total: len(allSeeds), Seeds: matched}) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Seed Search: %q (%d of %d)\n\n", query, len(matched), len(allSeeds))) if len(matched) == 0 { @@ -663,6 +753,10 @@ func (r *Registry) craftSeed(ctx context.Context, args []string) error { fmt.Println() gcolor.HEX("#94a3b8").Printf(" %d seeds in garden\n\n", len(seeds)) if len(seeds) == 0 { + if r.out.JSON() { + r.out.Data(craftSeedListResult{Count: 0}) + return nil + } fmt.Println(gcolor.HEX("#94a3b8").Sprint(" No seeds to elevate.")) fmt.Println() return nil @@ -696,7 +790,13 @@ func (r *Registry) craftSeed(ctx context.Context, args []string) error { gcolor.HEX("#94a3b8").Printf(" source: %s\n", elevateSource) fmt.Printf(" %s\n\n", gcolor.White.Sprint(truncate(elevateText, 100))) - if !craftConfirm("Store this as durable memory?") { + // Headless callers get a clean refusal instead of blocking on stdin — + // checked before the y/N prompt (one prompt maximum: this replaces it + // rather than running alongside it). + if err := r.headlessRefuse("store durable memory"); err != nil { + return err + } + if !r.autoConfirmed() && !craftConfirm("Store this as durable memory?") { fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Cancelled — nothing elevated.")) fmt.Println() return nil @@ -963,9 +1063,14 @@ func (r *Registry) craftScaffold(args []string) error { // Permission gate "craft.scaffold" replaces the old ad-hoc y/N confirm at // the same site, after the create-plan listing above so a Confirm-mode // user decides with the full file list on screen. + // Headless callers get a clean refusal instead of a silent decline — + // checked before the gate, same as /craft adr and /craft claude-md. + if err := r.headlessRefuse("scaffold project files"); err != nil { + return err + } if !r.permissionGate("craft.scaffold", fmt.Sprintf("create %d dir(s) and %d file(s) in %s", len(dirs), len(files), cwd), - false) { + r.autoConfirmed()) { return nil } fmt.Println() @@ -1149,8 +1254,19 @@ func craftSlug(title string) string { // craftPrintSeedList prints a seed listing in the shared /craft seed table // format, or an empty-garden hint when there are none. Shared by `seed ls` // and `seed harvest`, which — per the fix below — is now an honestly-labelled -// alias for ls rather than a silent one. +// alias for ls rather than a silent one, and by the no-args listing branch of +// `seed elevate`. +// +// Under a headless JSON dispatch it records the full seed list into the +// result envelope's `data` instead of printing — consulting the +// package-level curEmitter rather than taking a *Registry, the same +// free-function pattern printKV uses (see cmd_help.go), since this is shared +// by three call sites with no receiver of their own to thread through. func craftPrintSeedList(seeds []client.Seed) { + if curEmitter.JSON() { + curEmitter.Data(craftSeedListResult{Count: len(seeds), Seeds: seeds}) + return + } if len(seeds) == 0 { fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Garden is empty. Use /craft seed plant to add a seed.")) fmt.Println() @@ -1234,6 +1350,14 @@ func (r *Registry) craftStream(ctx context.Context, systemPrompt string, req cli } if chunk.Event == "error" { streamErrMsg = chunk.Data + // Mirrors internal/repl/renderer.go RenderJSON: EventError DOES + // flow through to a scripted consumer rather than being dropped + // silently, so a headless caller streaming NDJSON sees the + // failure the instant it happens instead of only at process + // exit via the terminal Envelope's `error` field. + if r.out.JSON() { + r.out.Emit(map[string]string{"type": "error", "content": chunk.Data}) + } return } // Parse delta payload using encoding/json for correct Unicode/escape handling. @@ -1241,17 +1365,32 @@ func (r *Registry) craftStream(ctx context.Context, systemPrompt string, req cli Delta string `json:"delta"` } if jsonErr := json.Unmarshal([]byte(chunk.Data), &delta); jsonErr == nil && delta.Delta != "" { - fmt.Print(delta.Delta) out.WriteString(delta.Delta) + if r.out.JSON() { + // NDJSON line per token — same {type,content} shape as the + // chat renderer's RenderJSON, so one client parser covers + // both /agent chat streaming and /craft adr|scout|claude-md. + r.out.Emit(map[string]string{"type": "text", "content": delta.Delta}) + } else { + fmt.Print(delta.Delta) + } return } // Fallback: print raw data for non-delta events (content, etc.). if chunk.Event == "" || chunk.Event == "message" || chunk.Event == "delta" { - fmt.Print(chunk.Data) out.WriteString(chunk.Data) + if r.out.JSON() { + r.out.Emit(map[string]string{"type": "text", "content": chunk.Data}) + } else { + fmt.Print(chunk.Data) + } } }) - if err == nil && streamErrMsg != "" && out.Len() == 0 { + // Any mid-stream gateway error fails the generation — not only when no text + // arrived. A partial ADR/scout followed by an error is still a failed run, + // and a headless caller must see ok=false (consistent with /run, /agent, + // /workflow). Dropping the former `out.Len() == 0` guard is the fix. + if err == nil && streamErrMsg != "" { err = fmt.Errorf("gateway stream error: %s", streamErrMsg) } return out.String(), err diff --git a/internal/commands/cmd_disposition.go b/internal/commands/cmd_disposition.go index 50806fc..e9aa208 100644 --- a/internal/commands/cmd_disposition.go +++ b/internal/commands/cmd_disposition.go @@ -56,6 +56,10 @@ func (r *Registry) dispositionList() error { return err } presets := config.MergeConfigProfiles(r.cfg.DispositionProfiles, filePresets) + if r.out.JSON() { + r.out.Data(presets) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Disposition presets (%d)", len(presets))) fmt.Println() @@ -108,6 +112,10 @@ func (r *Registry) dispositionShow(name string) error { presets := config.MergeConfigProfiles(r.cfg.DispositionProfiles, filePresets) for _, p := range presets { if p.Name == name { + if r.out.JSON() { + r.out.Data(p) + return nil + } fmt.Println() printKV("name", p.Name) printKV("pacing", p.Pacing) diff --git a/internal/commands/cmd_doctor.go b/internal/commands/cmd_doctor.go index 61d3268..8ed0d31 100644 --- a/internal/commands/cmd_doctor.go +++ b/internal/commands/cmd_doctor.go @@ -28,26 +28,47 @@ import ( // ─── /doctor ──────────────────────────────────────────────────────────────── +// doctorCheckResult is one structured check outcome, accumulated alongside +// the human OK/WARN lines so headless JSON mode gets the same information as +// a per-line structure instead of scraped terminal text. +type doctorCheckResult struct { + Section string `json:"section"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + func (r *Registry) doctorCmd() Command { return Command{ Name: "doctor", Usage: "/doctor", Short: "Read-only diagnostic: gateway, providers, config, plugins/hooks, protocol, harnesses", Run: func(ctx context.Context, args []string) error { - fmt.Println() - gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Dojo Doctor")) - fmt.Println() - fmt.Println(gcolor.HEX("#94a3b8").Sprint(" " + r.cfg.Gateway.URL)) - fmt.Println() + if !r.out.JSON() { + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Dojo Doctor")) + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" " + r.cfg.Gateway.URL)) + fmt.Println() + } + var results []doctorCheckResult + var currentSection string warnCount := 0 check := func(ok bool, detail string) { if !ok { warnCount++ } + results = append(results, doctorCheckResult{Section: currentSection, OK: ok, Detail: detail}) + if r.out.JSON() { + return + } fmt.Printf(" %s %s\n", doctorTag(ok), detail) } section := func(name string) { + currentSection = name + if r.out.JSON() { + return + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#f4a261").Sprint(" " + name)) fmt.Println() @@ -71,6 +92,14 @@ func (r *Registry) doctorCmd() Command { section("HARNESSES") r.doctorHarnesses(check) + if r.out.JSON() { + r.out.Data(map[string]any{ + "checks": results, + "warnings": warnCount, + }) + return nil + } + fmt.Println() if warnCount == 0 { fmt.Println(gcolor.HEX("#7fb88c").Sprint(" All checks OK")) diff --git a/internal/commands/cmd_garden.go b/internal/commands/cmd_garden.go index 5339216..d2cc05e 100644 --- a/internal/commands/cmd_garden.go +++ b/internal/commands/cmd_garden.go @@ -32,6 +32,10 @@ func (r *Registry) gardenCmd() Command { if err != nil { return fmt.Errorf("could not fetch garden stats: %w", err) } + if r.out.JSON() { + r.out.Data(stats) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Garden Stats")) fmt.Println() @@ -75,6 +79,10 @@ func (r *Registry) gardenCmd() Command { if err != nil { return fmt.Errorf("could not search memories: %w", err) } + if r.out.JSON() { + r.out.Data(results) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Search results (%d)\n\n", len(results))) if len(results) == 0 { @@ -103,14 +111,21 @@ func (r *Registry) gardenCmd() Command { fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Seed deleted")) fmt.Println() - default: // ls — also catches unrecognized subcommands (e.g. the - // former "harvest" verb, which was a silent no-op alias for - // this same branch; dropped rather than documented as if it - // were a distinct, intentional action). + default: // ls (bare /garden, or explicit /garden ls). Unrecognized + // subcommands (e.g. the former "harvest" verb) used to fall + // through silently to this same branch — now they error + // instead of pretending to be a distinct, intentional action. + if len(args) > 0 && sub != "ls" { + return fmt.Errorf("unknown subcommand %q — see /help", args[0]) + } seeds, err := r.gw.Seeds(ctx) if err != nil { return fmt.Errorf("could not fetch seeds: %w", err) } + if r.out.JSON() { + r.out.Data(seeds) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Seeds (%d)\n\n", len(seeds))) if len(seeds) == 0 { diff --git a/internal/commands/cmd_guide.go b/internal/commands/cmd_guide.go index 7ddd2a2..fba479e 100644 --- a/internal/commands/cmd_guide.go +++ b/internal/commands/cmd_guide.go @@ -44,12 +44,45 @@ func (r *Registry) guideCmd() Command { // ─── subcommand handlers ───────────────────────────────────────────────────── +// GuideListEntry is one row of the JSON-mode `/guide ls` payload. guide.Guide +// itself carries no JSON tags (it's a REPL-only display type), so this is the +// small tagged projection an agent can parse. +type GuideListEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Short string `json:"short,omitempty"` + Steps int `json:"steps"` + Completed bool `json:"completed"` + Active bool `json:"active"` + CurrentStep int `json:"current_step,omitempty"` // 1-based; only meaningful when Active +} + func guideLs() error { st, err := state.Load() if err != nil { return fmt.Errorf("loading state: %w", err) } + if curEmitter.JSON() { + entries := make([]GuideListEntry, 0, len(guide.All)) + for _, g := range guide.All { + e := GuideListEntry{ + ID: g.ID, + Title: g.Title, + Short: g.Short, + Steps: len(g.Steps), + Completed: guide.IsCompleted(st, g.ID), + Active: st.Guide.Active == g.ID, + } + if e.Active { + e.CurrentStep = st.Guide.Step + 1 + } + entries = append(entries, e) + } + curEmitter.Data(entries) + return nil + } + fmt.Println() fmt.Println(gcolor.HEX("#e8b04a").Sprint(" Guides")) fmt.Println() diff --git a/internal/commands/cmd_help.go b/internal/commands/cmd_help.go index 20b8dcc..54d8b08 100644 --- a/internal/commands/cmd_help.go +++ b/internal/commands/cmd_help.go @@ -61,12 +61,13 @@ func (r *Registry) helpCmd() Command { {"/garden rm ", "delete a seed", ""}, {"/trail", "show memory timeline", ""}, {"/trail add ", "store a memory entry", ""}, + {"/trail edit ", "edit a memory entry", ""}, {"/trail rm ", "delete a memory entry", ""}, {"/trail search ", "search memories", ""}, {"/snapshot", "list memory snapshots", "beta"}, {"/snapshot save", "save a snapshot of the active session", "beta"}, {"/snapshot restore ", "restore a snapshot", "beta"}, - {"/snapshot export ", "export a snapshot", "beta"}, + {"/snapshot export [path]", "export a snapshot (to file if path given)", "beta"}, {"/snapshot rm ", "delete a snapshot", "beta"}, }}, {"Workspace", []helpEntry{ @@ -85,6 +86,7 @@ func (r *Registry) helpCmd() Command { }}, {"Orchestration", []helpEntry{ {"/run ", "submit multi-step orchestration plan", ""}, + {"/run --plan ", "run a hand-authored ExecutionPlan (JSON)", ""}, {"/workflow [json]", "execute a workflow", ""}, {"/warroom [topic]", "split-panel debate: Scout vs Challenger", "beta"}, {"/pilot [plain]", "live SSE event stream, Ctrl+C to stop (add 'plain' for text-only)", ""}, @@ -149,6 +151,7 @@ func (r *Registry) helpCmd() Command { {"/activity [n]", "show recent activity log entries", ""}, {"/activity clear", "clear the activity log", ""}, {"/tools", "list registered MCP tools, grouped by namespace", ""}, + {"/tools ", "show a tool's input schema", ""}, {"/doc ", "fetch and display a document", "beta"}, }}, {"Protocol", []helpEntry{ @@ -199,7 +202,17 @@ func (r *Registry) helpCmd() Command { // ─── Shared formatting helpers ────────────────────────────────────────────── // printKV prints a key-value pair: key in cloud-gray, value in white. +// +// Under a headless JSON dispatch (curEmitter in JSON mode) it instead records +// the pair into the result envelope's `data` object — so the ~119 printKV call +// sites across the command surface yield a structured payload for free, with no +// per-call-site change. In the REPL/Human path curEmitter is nil and this +// prints exactly as before. func printKV(key, value string) { + if curEmitter.JSON() { + curEmitter.Field(key, value) + return + } fmt.Printf("%s%s\n", gcolor.HEX("#94a3b8").Sprintf(" %-24s", key), gcolor.White.Sprint(value), @@ -215,6 +228,14 @@ func truncate(s string, n int) string { } func colorStatus(s string) string { + // In a headless JSON dispatch, return the raw status so it lands in the + // envelope's `data` as clean text rather than an ANSI-wrapped string. + if curEmitter.JSON() { + if s == "" { + return "unknown" + } + return s + } switch strings.ToLower(s) { case "ok", "healthy", "active", "running", "ready", "completed": return gcolor.HEX("#7fb88c").Sprint(s) // soft-sage diff --git a/internal/commands/cmd_memory.go b/internal/commands/cmd_memory.go index 6011aac..7f80956 100644 --- a/internal/commands/cmd_memory.go +++ b/internal/commands/cmd_memory.go @@ -5,6 +5,7 @@ package commands import ( "context" "fmt" + "os" "strings" "github.com/DojoGenesis/cli/internal/client" @@ -16,7 +17,7 @@ import ( func (r *Registry) trailCmd() Command { return Command{ Name: "trail", - Usage: "/trail [add |rm |search ]", + Usage: "/trail [add |edit |rm |search ]", Short: "Show memory timeline or add/remove/search memories", Run: func(ctx context.Context, args []string) error { if len(args) == 0 { @@ -25,6 +26,10 @@ func (r *Registry) trailCmd() Command { if err != nil { return fmt.Errorf("could not fetch memory trail: %w", err) } + if r.out.JSON() { + r.out.Data(memories) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Memory Trail (%d)\n\n", len(memories))) if len(memories) == 0 { @@ -61,6 +66,21 @@ func (r *Registry) trailCmd() Command { } fmt.Println() + case "edit": + // /trail edit + if len(args) < 3 { + return fmt.Errorf("usage: /trail edit ") + } + id := args[1] + text := strings.Join(args[2:], " ") + if err := r.gw.UpdateMemory(ctx, id, client.UpdateMemoryRequest{Content: text}); err != nil { + return fmt.Errorf("could not update memory: %w", err) + } + fmt.Println() + fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Memory updated")) + printKV("id", id) + fmt.Println() + case "rm": // /trail rm if len(args) < 2 { @@ -84,6 +104,10 @@ func (r *Registry) trailCmd() Command { if err != nil { return fmt.Errorf("could not search memories: %w", err) } + if r.out.JSON() { + r.out.Data(results) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Search results (%d)\n\n", len(results))) if len(results) == 0 { @@ -100,7 +124,7 @@ func (r *Registry) trailCmd() Command { fmt.Println() default: - return fmt.Errorf("unknown trail subcommand %q — use: add, rm, search", sub) + return fmt.Errorf("unknown trail subcommand %q — use: add, edit, rm, search", sub) } return nil }, @@ -112,7 +136,7 @@ func (r *Registry) trailCmd() Command { func (r *Registry) snapshotCmd() Command { return Command{ Name: "snapshot", - Usage: "/snapshot [save|restore |export |rm ]", + Usage: "/snapshot [save|restore |export [path]|rm ]", Short: "List, save, restore, export, or delete memory snapshots", Run: func(ctx context.Context, args []string) error { sub := "ls" @@ -150,13 +174,37 @@ func (r *Registry) snapshotCmd() Command { case "export": if len(args) < 2 { - return fmt.Errorf("usage: /snapshot export ") + return fmt.Errorf("usage: /snapshot export [path]") } id := args[1] data, err := r.gw.ExportSnapshot(ctx, id) if err != nil { return fmt.Errorf("could not export snapshot: %w", err) } + if len(args) >= 3 { + // A path was given — write to disk instead of dumping to + // stdout, which is unusable in the REPL for anything but + // tiny snapshots. + path := args[2] + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("could not write snapshot to %q: %w", path, err) + } + if r.out.JSON() { + r.out.Data(map[string]any{"id": id, "path": path, "bytes": len(data)}) + return nil + } + fmt.Println() + fmt.Println(gcolor.HEX("#7fb88c").Sprint(" Snapshot exported")) + printKV("id", id) + printKV("path", path) + printKV("bytes", fmt.Sprintf("%d", len(data))) + fmt.Println() + return nil + } + if r.out.JSON() { + r.out.Data(map[string]any{"id": id, "bytes": len(data)}) + return nil + } fmt.Println(string(data)) case "rm": @@ -172,10 +220,17 @@ func (r *Registry) snapshotCmd() Command { fmt.Println() default: // ls + if len(args) > 0 && sub != "ls" { + return fmt.Errorf("unknown subcommand %q — see /help", args[0]) + } snaps, err := r.gw.ListSnapshots(ctx, *r.session) if err != nil { return fmt.Errorf("could not fetch snapshots: %w", err) } + if r.out.JSON() { + r.out.Data(snaps) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Snapshots (%d)\n\n", len(snaps))) if len(snaps) == 0 { diff --git a/internal/commands/cmd_model.go b/internal/commands/cmd_model.go index 046233b..e3024af 100644 --- a/internal/commands/cmd_model.go +++ b/internal/commands/cmd_model.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/DojoGenesis/cli/internal/activity" + "github.com/DojoGenesis/cli/internal/client" "github.com/DojoGenesis/cli/internal/providers" gcolor "github.com/gookit/color" ) @@ -42,6 +43,66 @@ func (r *Registry) modelCmd() Command { } } +// ─── JSON result types (headless mode) ────────────────────────────────────── + +// ModelCatalogModel is one statically-known model within a provider, in the +// JSON-mode `/model ls` catalog. providers.KnownModel carries no JSON tags +// (it's a REPL-only display type), so this is the small tagged projection. +type ModelCatalogModel struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Notes string `json:"notes,omitempty"` +} + +// ModelCatalogEntry is one provider's row in the JSON-mode `/model ls` +// catalog — the structured equivalent of providers.FormatProviderTable's +// human table, including the same "n/a" / "set" / "not set" key status. +type ModelCatalogEntry struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + KeyStatus string `json:"key_status"` // "n/a" | "set" | "not set" + Models []ModelCatalogModel `json:"models"` +} + +// ModelListResult is the JSON-mode payload for `/model ls` (and bare +// `/model`). GatewayProviders/GatewayModels are mutually exclusive — the +// gateway's /v1/providers is tried first, /v1/models is a fallback, and +// both are empty when the gateway is unreachable. +type ModelListResult struct { + Catalog []ModelCatalogEntry `json:"catalog"` + GatewayReachable bool `json:"gateway_reachable"` + GatewayProviders []client.Provider `json:"gateway_providers,omitempty"` + GatewayModels []client.Model `json:"gateway_models,omitempty"` + DelegationModel string `json:"delegation_model,omitempty"` +} + +// modelCatalogEntries builds the JSON-mode catalog rows from providers.Catalog, +// mirroring providers.FormatProviderTable's key-status logic exactly. +func modelCatalogEntries(keys providers.APIKeys) []ModelCatalogEntry { + entries := make([]ModelCatalogEntry, 0, len(providers.Catalog)) + for _, p := range providers.Catalog { + keyStatus := "n/a" + if p.EnvKey != "" { + if keys.KeyForProvider(p.ID) != "" { + keyStatus = "set" + } else { + keyStatus = "not set" + } + } + models := make([]ModelCatalogModel, 0, len(p.Models)) + for _, m := range p.Models { + models = append(models, ModelCatalogModel{ID: m.ID, DisplayName: m.DisplayName, Notes: m.Notes}) + } + entries = append(entries, ModelCatalogEntry{ + ID: p.ID, + DisplayName: p.DisplayName, + KeyStatus: keyStatus, + Models: models, + }) + } + return entries +} + func (r *Registry) modelList(ctx context.Context) error { // Always show static provider catalog first. keys := providers.LoadAPIKeys() @@ -63,11 +124,27 @@ func (r *Registry) modelList(ctx context.Context) error { gwModels, err2 := r.gw.Models(ctx) if err2 != nil { // Gateway is offline — that's fine, catalog was already shown. + if r.out.JSON() { + r.out.Data(ModelListResult{ + Catalog: modelCatalogEntries(keys), + DelegationModel: r.cfg.Delegation.Model, + }) + return nil + } fmt.Println(gcolor.HEX("#94a3b8").Sprint(" (gateway unreachable — showing catalog only)")) fmt.Println() r.printDelegationDefault() return nil } + if r.out.JSON() { + r.out.Data(ModelListResult{ + Catalog: modelCatalogEntries(keys), + GatewayReachable: true, + GatewayModels: gwModels, + DelegationModel: r.cfg.Delegation.Model, + }) + return nil + } gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Gateway models (%d)", len(gwModels))) fmt.Println() fmt.Println() @@ -82,6 +159,16 @@ func (r *Registry) modelList(ctx context.Context) error { return nil } + if r.out.JSON() { + r.out.Data(ModelListResult{ + Catalog: modelCatalogEntries(keys), + GatewayReachable: true, + GatewayProviders: gwProviders, + DelegationModel: r.cfg.Delegation.Model, + }) + return nil + } + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Gateway providers (%d)", len(gwProviders))) fmt.Println() fmt.Println() diff --git a/internal/commands/cmd_plugin.go b/internal/commands/cmd_plugin.go index 2539b8a..bfee649 100644 --- a/internal/commands/cmd_plugin.go +++ b/internal/commands/cmd_plugin.go @@ -5,6 +5,7 @@ package commands import ( "context" "fmt" + "strings" "github.com/DojoGenesis/cli/internal/activity" "github.com/DojoGenesis/cli/internal/plugins" @@ -14,13 +15,21 @@ import ( // pluginCmd returns the /plugin command with subcommands: // // /plugin ls — list installed plugins -// /plugin install — clone a plugin from a git URL +// /plugin install [--yes] [--sha256=] [--allow-any-source] +// — clone a plugin from a git URL // /plugin rm — remove an installed plugin +// +// install's trailing flags (any order, after the URL): +// +// --yes / -y — skip the y/N confirmation (never the integrity gate below) +// --sha256= — pin the installed tree to a known-good digest (plugins.HashPluginTree) +// --allow-any-source — disable the source allowlist (Rider C2); a separate, deliberate +// opt-out — never implied by --yes func (r *Registry) pluginCmd() Command { return Command{ Name: "plugin", Aliases: []string{"plugins"}, - Usage: "/plugin [ls|install |rm ]", + Usage: "/plugin [ls|install [--yes] [--sha256=] [--allow-any-source]|rm ]", Short: "Manage installed plugins", Run: func(ctx context.Context, args []string) error { if len(args) == 0 || args[0] == "ls" { @@ -31,8 +40,19 @@ func (r *Registry) pluginCmd() Command { if len(args) < 2 { return fmt.Errorf("usage: /plugin install ") } - noConfirm := len(args) > 2 && (args[2] == "--yes" || args[2] == "-y") - return r.pluginInstall(ctx, args[1], noConfirm) + var noConfirm, allowAnySource bool + var sha256Pin string + for _, a := range args[2:] { + switch { + case a == "--yes" || a == "-y": + noConfirm = true + case a == "--allow-any-source": + allowAnySource = true + case strings.HasPrefix(a, "--sha256="): + sha256Pin = strings.TrimPrefix(a, "--sha256=") + } + } + return r.pluginInstall(ctx, args[1], noConfirm, allowAnySource, sha256Pin) case "rm", "remove", "uninstall": if len(args) < 2 { return fmt.Errorf("usage: /plugin rm ") @@ -83,7 +103,19 @@ func (r *Registry) pluginList(ctx context.Context) error { // override an allowlist-mode Deny. InstallConfirmed itself is retained (it // lives in internal/plugins and still prints the security warning banner), // but its prompt path is dead from this call site. -func (r *Registry) pluginInstall(ctx context.Context, gitURL string, noConfirm bool) error { +// +// allowAnySource and sha256Pin surface plugins.InstallPolicy's two integrity +// knobs (Rider C2): allowAnySource disables the source allowlist entirely — +// a separate, explicitly-named flag, never implied by noConfirm/--yes — and +// sha256Pin, when non-empty, pins the installed tree to a known-good digest +// (plugins.HashPluginTree), rolling back the install on a mismatch. Neither +// knob is needed for the default path: InstallConfirmed (and therefore this +// call, when both are zero-valued) already applies the house allowlist via +// plugins.DefaultInstallPolicy. +func (r *Registry) pluginInstall(ctx context.Context, gitURL string, noConfirm, allowAnySource bool, sha256Pin string) error { + if err := r.headlessRefuse("install plugin"); err != nil { + return err + } if !r.permissionGate("plugin.install", fmt.Sprintf("clone %s into %s and load its hooks/skills", gitURL, r.cfg.Plugins.Path), noConfirm) { @@ -93,7 +125,11 @@ func (r *Registry) pluginInstall(ctx context.Context, gitURL string, noConfirm b fmt.Println() fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" Cloning %s ...", gitURL)) - results, err := plugins.InstallConfirmed(gitURL, r.cfg.Plugins.Path, true) + policy := plugins.DefaultInstallPolicy() + policy.AllowAnySource = allowAnySource + policy.ExpectedSHA256 = sha256Pin + + results, err := plugins.InstallConfirmedWithPolicy(gitURL, r.cfg.Plugins.Path, true, policy) if err != nil { return fmt.Errorf("plugin install: %w", err) } diff --git a/internal/commands/cmd_protocol.go b/internal/commands/cmd_protocol.go index 1aeef74..3b09cb4 100644 --- a/internal/commands/cmd_protocol.go +++ b/internal/commands/cmd_protocol.go @@ -38,6 +38,7 @@ import ( "context" "encoding/json" "fmt" + "io" "io/fs" "os" "os/exec" @@ -272,7 +273,7 @@ func (r *Registry) protocolCmd() Command { case "show": return r.protocolShow() case "edit": - return r.protocolEdit(ctx) + return r.protocolEditDispatch(ctx, args[1:]) case "harnesses", "harness", "ls", "list": return r.protocolHarnesses() case "install", "add": @@ -438,6 +439,16 @@ func (r *Registry) protocolEdit(ctx context.Context) error { return nil } + // Past this point we hand the terminal to a subprocess (cmd.Stdin = + // os.Stdin below) — fine in the REPL, but a headless dispatch has no + // real terminal to hand over, so it would either hang or misbehave + // against whatever stdin actually is. Refuse cleanly instead, unless + // --yolo. The "no $EDITOR/$VISUAL" guidance above stays available + // headlessly since it never touches a subprocess. + if err := r.headlessRefuse("edit protocol doc"); err != nil { + return err + } + // $EDITOR/$VISUAL may carry arguments (e.g. "code --wait") — split on // whitespace rather than treating the whole string as one binary name, the // way a shell would locate the program vs. its flags. No shell is @@ -461,6 +472,84 @@ func (r *Registry) protocolEdit(ctx context.Context) error { return nil } +// protocolEditDispatch routes /protocol edit. With --content it writes the +// overlay directly — no $EDITOR, no subprocess, no stdin block — so the doc is +// editable headlessly. Without --content it falls through to the interactive +// editor flow (protocolEdit), byte-for-byte unchanged. +// +// Content forms (explicit opt-in only — it NEVER auto-reads stdin, which would +// hang a headless caller whose stdin isn't a real terminal): +// +// --content space form; joins the remaining args (the dispatcher's +// tokenizer has no quoting, so this is single-line only) +// --content= inline form +// --content - reads all of stdin (Unix convention) — the way to pipe +// --content=- a real multi-line protocol doc +func (r *Registry) protocolEditDispatch(ctx context.Context, args []string) error { + content, hasContent, err := parseContentFlag(args) + if err != nil { + return err + } + if !hasContent { + return r.protocolEdit(ctx) // interactive $EDITOR flow, unchanged + } + + cwd, _ := os.Getwd() + target, err := protocolResolveEditTarget(cwd, config.DojoDir()) + if err != nil { + return err + } + if err := os.WriteFile(target, []byte(content), 0644); err != nil { + return fmt.Errorf("write protocol overlay %s: %w", target, err) + } + + if r.out.JSON() { + r.out.Data(map[string]any{"path": target, "bytes": len(content)}) + return nil + } + fmt.Println() + fmt.Println(gcolor.HEX("#7fb88c").Sprintf(" Protocol overlay written: %s (%d bytes)", target, len(content))) + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" Changes take effect next session — the protocol resolves once at session start.")) + fmt.Println() + return nil +} + +// parseContentFlag extracts a --content value. Returns (content, true, nil) when +// present, ("", false, nil) when absent. A bare "-" (or "=-") reads stdin; the +// space form joins the remaining args. See protocolEditDispatch for the forms. +func parseContentFlag(args []string) (string, bool, error) { + for i, a := range args { + switch { + case a == "--content": + rest := args[i+1:] + if len(rest) == 0 { + return "", false, fmt.Errorf("usage: /protocol edit --content ('-' reads stdin)") + } + if len(rest) == 1 && rest[0] == "-" { + return readAllStdin() + } + return strings.Join(rest, " "), true, nil + case strings.HasPrefix(a, "--content="): + v := strings.TrimPrefix(a, "--content=") + if v == "-" { + return readAllStdin() + } + return v, true, nil + } + } + return "", false, nil +} + +// readAllStdin consumes all of stdin as the --content value. Only ever reached +// via an explicit "-" so a caller can never hang on it by accident. +func readAllStdin() (string, bool, error) { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", false, fmt.Errorf("read --content from stdin: %w", err) + } + return string(b), true, nil +} + // ─── harnesses ──────────────────────────────────────────────────────────────── // protocolHarnesses lists the KE catalog: name, status, ratified/draft, and @@ -571,11 +660,21 @@ func (r *Registry) protocolInstall(ctx context.Context, name string, noConfirm b return nil } + // Past this point the only remaining branch either prompts on stdin (no + // noConfirm) or copies a plugin tree unattended (noConfirm) — both need + // interactive confirmation somewhere in the loop, so a headless caller + // without --yolo is refused here rather than either hanging on stdin or + // installing unattended. The gates above (unratified, no local plugin, + // already installed) are informational and stay available headlessly. + if err := r.headlessRefuse("install protocol harness"); err != nil { + return err + } + fmt.Println() fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" Install harness %q", target.ID)) fmt.Println(gcolor.HEX("#94a3b8").Sprint(" from: " + src)) fmt.Println(gcolor.HEX("#94a3b8").Sprint(" to: " + dst)) - if !noConfirm { + if !noConfirm && !r.autoConfirmed() { fmt.Print(gcolor.HEX("#e8b04a").Sprint("\n Continue? [y/N]: ")) reader := bufio.NewReader(os.Stdin) answer, _ := reader.ReadString('\n') diff --git a/internal/commands/cmd_session.go b/internal/commands/cmd_session.go index c07a095..db42bcd 100644 --- a/internal/commands/cmd_session.go +++ b/internal/commands/cmd_session.go @@ -126,6 +126,14 @@ func sessionResumeWarning(id string, hist []state.SessionEntry) string { return fmt.Sprintf("%q not found in local session history", id) } +// SessionListEntry is one row of the JSON-mode `/session ls` payload: the +// stored history entry plus whether it's the REPL's currently active +// session, which isn't itself a field on state.SessionEntry. +type SessionListEntry struct { + state.SessionEntry + Active bool `json:"active"` +} + // sessionLs lists recent sessions from local history, most-recent-first, // marking whichever one is currently active in this REPL. Claude-Code-style // `/session ls`. @@ -136,6 +144,15 @@ func sessionLs(active string) error { } hist := st.History() + if curEmitter.JSON() { + entries := make([]SessionListEntry, len(hist)) + for i, e := range hist { + entries[i] = SessionListEntry{SessionEntry: e, Active: e.ID == active} + } + curEmitter.Data(entries) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Sessions (%d)\n\n", len(hist))) if len(hist) == 0 { diff --git a/internal/commands/cmd_skill.go b/internal/commands/cmd_skill.go index d167e15..c288a4b 100644 --- a/internal/commands/cmd_skill.go +++ b/internal/commands/cmd_skill.go @@ -34,7 +34,7 @@ func (r *Registry) skillCmd() Command { return Command{ Name: "skill", Aliases: []string{"skills"}, - Usage: "/skill [ls [filter]|search |get |inspect |tags|package-all ]", + Usage: "/skill [ls [filter]|search |get [@ver]|inspect |tags|package-all [ver]]", Short: "List, fetch, or inspect skills from CAS", Run: func(ctx context.Context, args []string) error { sub := "ls" @@ -54,6 +54,11 @@ func (r *Registry) skillCmd() Command { return fmt.Errorf("search failed: %w", err) } + if r.out.JSON() { + r.out.Data(skills) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Skills matching %q (%d)\n\n", query, len(skills))) @@ -100,12 +105,20 @@ func (r *Registry) skillCmd() Command { } return printExternalSkill(ext) } - tag, err := r.gw.CASResolveTag(ctx, name, "latest") + // Optional @ suffix — e.g. "my-skill@1.2.0". Absent + // defaults to "latest", matching the prior hardcoded behavior. + baseName, ver, hasVer := strings.Cut(name, "@") + if !hasVer { + ver = "latest" + } + tag, err := r.gw.CASResolveTag(ctx, baseName, ver) if err != nil { // Gateway lookup missed: fall back to external skills // (read-only). A miss on both surfaces the existing - // gateway error unchanged. - if ext := skills.FindExternal(r.externalSkillDirs(), name); ext != nil { + // gateway error unchanged. External skills have no + // version concept, so the fallback looks up baseName + // (the name with any @version suffix stripped). + if ext := skills.FindExternal(r.externalSkillDirs(), baseName); ext != nil { return printExternalSkill(ext) } return fmt.Errorf("could not resolve tag %q: %w", name, err) @@ -114,6 +127,17 @@ func (r *Registry) skillCmd() Command { if err != nil { return fmt.Errorf("could not fetch content for ref %q: %w", tag.Ref, err) } + + if r.out.JSON() { + r.out.Data(map[string]any{ + "name": tag.Name, + "version": tag.Version, + "ref": tag.Ref, + "content": string(content), + }) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Skill: %s @ %s\n\n", tag.Name, tag.Version)) printKV("ref", tag.Ref) @@ -132,6 +156,12 @@ func (r *Registry) skillCmd() Command { if err != nil { return fmt.Errorf("could not fetch content for ref %q: %w", ref, err) } + + if r.out.JSON() { + r.out.Data(map[string]any{"ref": ref, "content": string(content)}) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" CAS ref: %s\n\n", ref)) fmt.Println(gcolor.White.Sprint(string(content))) @@ -144,6 +174,12 @@ func (r *Registry) skillCmd() Command { if err != nil { return fmt.Errorf("could not list CAS tags: %w", err) } + + if r.out.JSON() { + r.out.Data(tags) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" CAS Tags (%d)\n\n", len(tags))) if len(tags) == 0 { @@ -169,7 +205,7 @@ func (r *Registry) skillCmd() Command { return nil case "package-all": - // /skill package-all [dir] + // /skill package-all [dir] [ver] // Walk a directory for SKILL.md files, put each into CAS, and create tags. // Default source: $DOJO_SKILLS_PATH or current directory. // NOTE: this deliberately walks ONLY its explicit arg or @@ -180,7 +216,13 @@ func (r *Registry) skillCmd() Command { dir = args[1] } if dir == "" { - return fmt.Errorf("usage: /skill package-all \n or set DOJO_SKILLS_PATH") + return fmt.Errorf("usage: /skill package-all [ver]\n or set DOJO_SKILLS_PATH") + } + // Optional trailing version arg — every packaged skill is + // tagged name@ver instead of the hardcoded name@latest. + ver := "latest" + if len(args) >= 3 { + ver = args[2] } // Walk for SKILL.md files. @@ -199,6 +241,10 @@ func (r *Registry) skillCmd() Command { } if len(skills) == 0 { + if r.out.JSON() { + r.out.Data(map[string]any{"succeeded": 0, "failed": 0, "total": 0, "version": ver}) + return nil + } fmt.Println() fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" No SKILL.md files found in %s", dir)) fmt.Println() @@ -216,6 +262,7 @@ func (r *Registry) skillCmd() Command { content, err := os.ReadFile(path) if err != nil { gcolor.HEX("#ef4444").Printf(" [FAIL] %s: %s\n", skillName, err) + r.out.Emit(map[string]any{"skill": skillName, "status": "fail", "error": err.Error()}) failed++ continue } @@ -227,13 +274,15 @@ func (r *Registry) skillCmd() Command { time.Sleep(250 * time.Millisecond) if err != nil { gcolor.HEX("#ef4444").Printf(" [FAIL] %s: CAS put: %s\n", skillName, err) + r.out.Emit(map[string]any{"skill": skillName, "status": "fail", "error": err.Error()}) failed++ continue } - // Create tag: name@latest -> ref. - if err := r.gw.CASCreateTag(ctx, skillName, "latest", ref); err != nil { + // Create tag: name@ver -> ref (ver defaults to "latest"). + if err := r.gw.CASCreateTag(ctx, skillName, ver, ref); err != nil { gcolor.HEX("#ef4444").Printf(" [FAIL] %s: tag create: %s\n", skillName, err) + r.out.Emit(map[string]any{"skill": skillName, "status": "fail", "error": err.Error()}) failed++ time.Sleep(250 * time.Millisecond) continue @@ -241,9 +290,17 @@ func (r *Registry) skillCmd() Command { time.Sleep(250 * time.Millisecond) gcolor.HEX("#22c55e").Printf(" [OK] %s → %s\n", skillName, shortRef(ref)) + r.out.Emit(map[string]any{"skill": skillName, "status": "ok", "ref": shortRef(ref), "version": ver}) succeeded++ } + r.out.Data(map[string]any{ + "succeeded": succeeded, + "failed": failed, + "total": len(skills), + "version": ver, + }) + fmt.Println() summary := fmt.Sprintf(" Done: %d succeeded, %d failed", succeeded, failed) if failed > 0 { @@ -265,6 +322,28 @@ func (r *Registry) skillCmd() Command { // group correctly regardless of gateway metadata completeness. skillList := skills.EnrichCategories(rawSkills) + // Filter by name, category, or plugin — computed once, ahead + // of both the JSON short-circuit below and the human display + // branches further down, so both paths see the same result. + displaySkills := skillList + if filter != "" { + fl := strings.ToLower(filter) + var matched []client.Skill + for _, s := range skillList { + if strings.Contains(strings.ToLower(s.Name), fl) || + strings.Contains(strings.ToLower(s.Category), fl) || + strings.Contains(strings.ToLower(s.Plugin), fl) { + matched = append(matched, s) + } + } + displaySkills = matched + } + + if r.out.JSON() { + r.out.Data(displaySkills) + return nil + } + if len(skillList) == 0 { fmt.Println() fmt.Println(gcolor.HEX("#94a3b8").Sprint(" No skills found.")) @@ -282,21 +361,6 @@ func (r *Registry) skillCmd() Command { return nil } - // Filter by name, category, or plugin. - displaySkills := skillList - if filter != "" { - fl := strings.ToLower(filter) - var matched []client.Skill - for _, s := range skillList { - if strings.Contains(strings.ToLower(s.Name), fl) || - strings.Contains(strings.ToLower(s.Category), fl) || - strings.Contains(strings.ToLower(s.Plugin), fl) { - matched = append(matched, s) - } - } - displaySkills = matched - } - if err := printSkillsPage(displaySkills, filter, page); err != nil { return err } @@ -590,6 +654,21 @@ func printExternalSkill(ext *skills.ExternalSkill) error { if err != nil { return fmt.Errorf("could not read external skill %q: %w", ext.Name, err) } + + // This free function has no *Registry receiver (it is shared by the + // /skill get ext: case and the gateway-miss fallback), so it + // consults the package-level curEmitter the same way printKV does. + if curEmitter.JSON() { + curEmitter.Data(map[string]any{ + "name": ext.Name, + "path": ext.Path, + "sourceDir": ext.SourceDir, + "external": true, + "content": string(content), + }) + return nil + } + fmt.Println() fmt.Printf(" %s\n\n", gcolor.HEX("#94a3b8").Sprintf("external skill (read-only): %s", ext.Path)) fmt.Println(mdrender.RenderMarkdown(string(content))) diff --git a/internal/commands/cmd_spirit.go b/internal/commands/cmd_spirit.go index 990b955..771fd4e 100644 --- a/internal/commands/cmd_spirit.go +++ b/internal/commands/cmd_spirit.go @@ -16,6 +16,15 @@ import ( // ─── /sensei ──────────────────────────────────────────────────────────────── +// SenseiResult is the JSON-mode payload for `/sensei`. +type SenseiResult struct { + Koan string `json:"koan"` + Belt string `json:"belt"` + Rank int `json:"rank"` + Unlocked int `json:"koans_unlocked"` + Total int `json:"koans_total"` +} + func (r *Registry) senseiCmd() Command { return Command{ Name: "sensei", @@ -33,6 +42,17 @@ func (r *Registry) senseiCmd() Command { unlocked := spirit.KoanCount(belt.Rank) total := spirit.TotalKoans() + if r.out.JSON() { + r.out.Data(SenseiResult{ + Koan: koan, + Belt: belt.Name, + Rank: belt.Rank, + Unlocked: unlocked, + Total: total, + }) + return nil + } + fmt.Println() fmt.Print(art.SmallBonsaiString()) fmt.Println() @@ -50,6 +70,37 @@ func (r *Registry) senseiCmd() Command { // ─── /card ────────────────────────────────────────────────────────────────── +// CardBelt is the belt projection used in CardResult — spirit.Belt carries no +// JSON tags (it's a REPL-only display type, and its Threshold isn't +// meaningful outside the belt ladder itself), so this is the small tagged +// subset an agent needs. +type CardBelt struct { + Rank int `json:"rank"` + Name string `json:"name"` + Title string `json:"title"` +} + +// CardAchievement is one unlocked achievement in the JSON-mode `/card` +// payload. spirit.Achievement carries an unexported `check` func field (which +// json.Marshal would simply skip) but no JSON tags on the rest, hence the +// small tagged projection here. +type CardAchievement struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` +} + +// CardResult is the JSON-mode payload for `/card`. +type CardResult struct { + Spirit spirit.SpiritState `json:"spirit"` + Belt CardBelt `json:"belt"` + NextBelt *CardBelt `json:"next_belt,omitempty"` + ProgressPct float64 `json:"progress_pct"` + XPToNext int `json:"xp_to_next,omitempty"` + Achievements []CardAchievement `json:"achievements"` +} + func (r *Registry) cardCmd() Command { return Command{ Name: "card", @@ -64,10 +115,28 @@ func (r *Registry) cardCmd() Command { sp := st.Spirit belt := spirit.CurrentBelt(sp.XP) - next, _ := spirit.NextBelt(sp.XP) + next, xpToNext := spirit.NextBelt(sp.XP) pct := spirit.ProgressPercent(sp.XP) achievements := spirit.UnlockedAchievements(&sp) + if r.out.JSON() { + result := CardResult{ + Spirit: sp, + Belt: CardBelt{Rank: belt.Rank, Name: belt.Name, Title: belt.Title}, + ProgressPct: pct, + Achievements: make([]CardAchievement, len(achievements)), + } + if next != nil { + result.NextBelt = &CardBelt{Rank: next.Rank, Name: next.Name, Title: next.Title} + result.XPToNext = xpToNext + } + for i, a := range achievements { + result.Achievements[i] = CardAchievement{ID: a.ID, Name: a.Name, Description: a.Description, Icon: a.Icon} + } + r.out.Data(result) + return nil + } + // XP display var xpLine string if next == nil { diff --git a/internal/commands/cmd_system.go b/internal/commands/cmd_system.go index a2f2e37..42d01e2 100644 --- a/internal/commands/cmd_system.go +++ b/internal/commands/cmd_system.go @@ -101,6 +101,14 @@ func (r *Registry) homeCmd() Command { return r.homePlain(ctx) } + // Headless (JSON dispatch, or any non-interactive run) has no + // terminal to paint an alt-screen TUI into -- route to the same + // plain overview "/home plain" uses instead of launching + // Bubbletea, which would hang or corrupt the dispatch. + if r.out.JSON() || r.headless { + return r.homePlain(ctx) + } + // Default: Bubbletea TUI panel model := tui.NewHomeModel(r.cfg, r.gw, *r.session, len(r.plgs)) p := tea.NewProgram(model, tea.WithAltScreen()) @@ -110,6 +118,22 @@ func (r *Registry) homeCmd() Command { } } +// homeSnapshot is /home's structured JSON payload -- one typed object +// instead of scraping the aligned key/value table homePlain prints below. +// Agent/seed counts and their fetch errors are mutually exclusive per field +// (an errored fetch has no count), mirroring the human view's "unavailable" +// vs. count display. +type homeSnapshot struct { + Gateway string `json:"gateway"` + GatewayStatus string `json:"gateway_status"` + AgentCount int `json:"agent_count,omitempty"` + AgentsError string `json:"agents_error,omitempty"` + SeedCount int `json:"seed_count,omitempty"` + SeedsError string `json:"seeds_error,omitempty"` + Plugins int `json:"plugins"` + Session string `json:"session"` +} + func (r *Registry) homePlain(ctx context.Context) error { h, err := r.gw.Health(ctx) if err != nil { @@ -118,6 +142,27 @@ func (r *Registry) homePlain(ctx context.Context) error { agents, agentErr := r.gw.Agents(ctx) seeds, seedErr := r.gw.Seeds(ctx) + if r.out.JSON() { + snap := homeSnapshot{ + Gateway: r.cfg.Gateway.URL, + GatewayStatus: h.Status, + Plugins: len(r.plgs), + Session: *r.session, + } + if agentErr != nil { + snap.AgentsError = agentErr.Error() + } else { + snap.AgentCount = len(agents) + } + if seedErr != nil { + snap.SeedsError = seedErr.Error() + } else { + snap.SeedCount = len(seeds) + } + r.out.Data(snap) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Dojo Workspace")) fmt.Println() @@ -151,6 +196,13 @@ func (r *Registry) settingsCmd() Command { Short: "Show active config and settings, or manage provider keys and disposition profiles", Run: func(ctx context.Context, args []string) error { if len(args) == 0 { + if r.out.JSON() { + data := effectiveSettingsData(r.cfg.EffectiveString()) + data["config_file"] = config.SettingsPath() + data["plugins_loaded"] = fmt.Sprintf("%d", len(r.plgs)) + r.out.Data(data) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Active Settings")) fmt.Println() @@ -165,6 +217,10 @@ func (r *Registry) settingsCmd() Command { sub := strings.ToLower(args[0]) switch sub { case "effective": + if r.out.JSON() { + r.out.Data(effectiveSettingsData(r.cfg.EffectiveString())) + return nil + } fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Effective Configuration")) fmt.Println() @@ -213,6 +269,24 @@ func (r *Registry) settingsCmd() Command { } } +// effectiveSettingsData parses cfg.EffectiveString()'s "key = value" lines +// into a map for JSON mode. It reuses the exact same rendering the human +// path already prints — including its gateway-token masking (EffectiveString +// never puts the raw token in the string) — rather than re-deriving values +// from cfg directly, so a headless caller can never see a field the human +// view redacts. +func effectiveSettingsData(effective string) map[string]string { + out := make(map[string]string) + for _, line := range strings.Split(strings.TrimRight(effective, "\n"), "\n") { + k, v, ok := strings.Cut(line, " = ") + if !ok { + continue + } + out[k] = v + } + return out +} + // settingsProfile handles /settings profile [ls|set |show |create ...] // Delegates to the disposition handlers so both /disposition and /settings profile // operate on the same state. @@ -245,6 +319,17 @@ func (r *Registry) settingsProfile(args []string) error { // ─── /hooks ───────────────────────────────────────────────────────────────── +// hookRuleInfo is one row of /hooks ls's structured JSON payload — one hook +// callback within one plugin's rule, flattened out of the nested +// plugin -> rule -> hook loop the human table below renders. +type hookRuleInfo struct { + Plugin string `json:"plugin"` + Event string `json:"event"` + Type string `json:"type"` + Target string `json:"target"` // whichever of command/prompt/URL the hook carries + Async bool `json:"async"` +} + func (r *Registry) hooksCmd() Command { return Command{ Name: "hooks", @@ -269,7 +354,37 @@ func (r *Registry) hooksCmd() Command { fmt.Println(gcolor.HEX("#7fb88c").Sprint(" done")) fmt.Println() - default: // ls + case "ls": + if r.out.JSON() { + // Initialized (not `var rows []hookRuleInfo`) so a + // no-hooks workspace serializes as `[]`, not `null` — a + // JSON list consumer should never have to special-case + // null vs. empty for "nothing here". + rows := []hookRuleInfo{} + for _, p := range r.plgs { + for _, rule := range p.HookRules { + for _, h := range rule.Hooks { + target := h.Command + if target == "" { + target = h.Prompt + } + if target == "" { + target = h.URL + } + rows = append(rows, hookRuleInfo{ + Plugin: p.Name, + Event: rule.Event, + Type: h.Type, + Target: target, + Async: h.Async, + }) + } + } + } + r.out.Data(rows) + return nil + } + // Count total rules totalRules := 0 for _, p := range r.plgs { @@ -318,6 +433,14 @@ func (r *Registry) hooksCmd() Command { } } fmt.Println() + + default: + // D1 — an unrecognized subcommand used to fall silently into + // "ls" (same failure mode fixed in /agent — see cmd_agent.go). + // Bare "/hooks" still lists (sub defaults to "ls" above, + // which the "ls" case handles), so only a real unrecognized + // token reaches here. + return fmt.Errorf("unknown subcommand %q — see /help", args[0]) } return nil }, diff --git a/internal/commands/cmd_telemetry.go b/internal/commands/cmd_telemetry.go index e059d69..f2652e1 100644 --- a/internal/commands/cmd_telemetry.go +++ b/internal/commands/cmd_telemetry.go @@ -57,17 +57,17 @@ type telemetryCostRow struct { } type telemetryProviderRow struct { - Provider string `json:"provider"` - TotalCost float64 `json:"total_cost"` - TotalTokenIn int64 `json:"total_tokens_in"` - TotalTokenOut int64 `json:"total_tokens_out"` - Count int `json:"count"` + Provider string `json:"provider"` + TotalCost float64 `json:"total_cost"` + TotalTokenIn int64 `json:"total_tokens_in"` + TotalTokenOut int64 `json:"total_tokens_out"` + Count int `json:"count"` } type telemetryDailyRow struct { - Day string `json:"day"` - TotalCost float64 `json:"total_cost"` - TotalTokens int64 `json:"total_tokens"` + Day string `json:"day"` + TotalCost float64 `json:"total_cost"` + TotalTokens int64 `json:"total_tokens"` } // API response wrapper: GET /api/telemetry/tools returns {"tools": [...]} @@ -177,6 +177,11 @@ func telemetrySessions(ctx context.Context) error { return fmt.Errorf("parse sessions: %w", err) } + if curEmitter.JSON() { + curEmitter.Data(resp) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Recent Sessions")) fmt.Println() @@ -225,6 +230,11 @@ func telemetryCosts(ctx context.Context) error { return fmt.Errorf("parse costs: %w", err) } + if curEmitter.JSON() { + curEmitter.Data(costs) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Cost Summary (7d)")) fmt.Println() @@ -283,6 +293,11 @@ func telemetryTools(ctx context.Context) error { return fmt.Errorf("parse tools: %w", err) } + if curEmitter.JSON() { + curEmitter.Data(resp) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Tool Usage (7d)")) fmt.Println() @@ -317,15 +332,20 @@ func telemetryTools(ctx context.Context) error { // ─── /telemetry summary ─────────────────────────────────────────────────── func telemetrySummary(ctx context.Context) error { - fmt.Println() - gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Telemetry Summary")) - fmt.Println() - // Fetch all three endpoints sessData, sessErr := telemetryGet(ctx, "/api/telemetry/sessions?limit=5") costsData, costsErr := telemetryGet(ctx, "/api/telemetry/costs?range=7d") toolsData, toolsErr := telemetryGet(ctx, "/api/telemetry/tools?range=7d") + if curEmitter.JSON() { + curEmitter.Data(telemetrySummaryPayload(sessData, sessErr, costsData, costsErr, toolsData, toolsErr)) + return nil + } + + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Telemetry Summary")) + fmt.Println() + // Cost overview fmt.Println() gcolor.Bold.Print(gcolor.HEX("#94a3b8").Sprint(" Costs (7d)")) @@ -404,6 +424,45 @@ func telemetrySummary(ctx context.Context) error { return nil } +// telemetrySummaryPayload assembles the JSON-mode `data` for /telemetry +// summary from the three raw fetches telemetrySummary already made. Each +// endpoint is independent: a fetch error for one surfaces as its own +// "_error" string key rather than failing the whole command — matching +// the human path, which renders each section's error inline and keeps +// rendering the other two. +func telemetrySummaryPayload(sessData []byte, sessErr error, costsData []byte, costsErr error, toolsData []byte, toolsErr error) map[string]any { + summary := map[string]any{} + + if costsErr != nil { + summary["costs_error"] = costsErr.Error() + } else { + var costs telemetryCostsResponse + if err := json.Unmarshal(costsData, &costs); err == nil { + summary["costs"] = costs + } + } + + if sessErr != nil { + summary["sessions_error"] = sessErr.Error() + } else { + var sessResp telemetrySessionsResponse + if err := json.Unmarshal(sessData, &sessResp); err == nil { + summary["sessions"] = sessResp.Sessions + } + } + + if toolsErr != nil { + summary["tools_error"] = toolsErr.Error() + } else { + var toolsResp telemetryToolsResponse + if err := json.Unmarshal(toolsData, &toolsResp); err == nil { + summary["tools"] = toolsResp.Tools + } + } + + return summary +} + // ─── Formatting helpers ─────────────────────────────────────────────────── // fmtTokens formats a token count with K/M suffixes. diff --git a/internal/commands/cmd_warroom.go b/internal/commands/cmd_warroom.go index f60648c..89b6379 100644 --- a/internal/commands/cmd_warroom.go +++ b/internal/commands/cmd_warroom.go @@ -21,13 +21,52 @@ func (r *Registry) warroomCmd() Command { Usage: "/warroom [topic]", Short: "Split-panel debate: Scout vs Challenger", Run: func(ctx context.Context, args []string) error { + // /warroom has no plain-text fallback — it IS the split-panel + // debate TUI. A headless JSON dispatch (or any non-interactive + // run) has no terminal to paint an alt-screen Bubbletea program + // into, so refuse cleanly instead of hanging or spraying + // screen-control bytes into a JSON stream. sessionID := fmt.Sprintf("warroom-%d", time.Now().UnixMilli()) if r.session != nil && *r.session != "" { sessionID = *r.session + "-warroom" } - topic := strings.Join(args, " ") + // Headless: run the debate without the alt-screen TUI. Stream both + // agents' turns as labeled NDJSON (JSON mode) or print a linear + // transcript (plain), and return both full transcripts in the + // envelope. A topic is required here — the interactive TUI can prompt + // for one, a non-interactive caller cannot. + if r.out.JSON() || r.headless { + if strings.TrimSpace(topic) == "" { + return fmt.Errorf("/warroom needs a topic outside the interactive TUI, e.g. /warroom should we ship") + } + var onChunk func(agent, text string) + if r.out.JSON() { + onChunk = func(agent, text string) { + r.out.Emit(map[string]any{"stream": "warroom", "agent": agent, "content": text}) + } + } + scout, challenger, derr := tui.RunWarRoomHeadless( + ctx, r.cfg.Gateway.URL, r.cfg.Gateway.Token, + r.cfg.Defaults.Model, r.cfg.Defaults.Provider, + sessionID, topic, onChunk, + ) + if r.out.JSON() { + r.out.Data(map[string]any{"topic": topic, "scout": scout, "challenger": challenger}) + return derr + } + fmt.Println() + fmt.Println(" SCOUT") + fmt.Println(scout) + fmt.Println() + fmt.Println(" CHALLENGER") + fmt.Println(challenger) + fmt.Println() + return derr + } + + // Interactive REPL: the split-panel alt-screen TUI (unchanged). model := tui.NewWarRoomModel( r.cfg.Gateway.URL, r.cfg.Gateway.Token, diff --git a/internal/commands/cmd_workflow.go b/internal/commands/cmd_workflow.go index 79b51b4..ba49a0e 100644 --- a/internal/commands/cmd_workflow.go +++ b/internal/commands/cmd_workflow.go @@ -60,24 +60,66 @@ func (r *Registry) workflowCmd() Command { gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" dojo ")) - // Stream progress + // Stream progress. Headless JSON mode emits one streamEvent NDJSON + // line per chunk (r.out.Emit — real stdout, bypassing the + // RunHeadless stdout redirect) instead of the colored human + // text; the two are mutually exclusive per event, same as + // streamAgentChat in cmd_agent.go. + var streamErrMsg string err = r.gw.WorkflowExecutionStream(ctx, resp.RunID, func(chunk client.SSEChunk) { switch chunk.Event { + case "error": + // Mid-stream gateway error: capture it so /workflow returns + // non-nil (headless envelope ok=false / exit 1), mirroring + // /run. Still emit the NDJSON error line in JSON mode. + streamErrMsg = agentNestedField(chunk.Data, "message") + if streamErrMsg == "" { + var m map[string]any + if json.Unmarshal([]byte(chunk.Data), &m) == nil { + if e, ok := m["error"].(string); ok { + streamErrMsg = e + } + } + } + if streamErrMsg == "" { + streamErrMsg = truncate(strings.TrimSpace(chunk.Data), 160) + } + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "error", Content: streamErrMsg}) + } + return case "thinking": + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "thinking", Content: truncate(chunk.Data, 80)}) + return + } fmt.Print(gcolor.HEX("#94a3b8").Sprint("\n [Thinking] " + truncate(chunk.Data, 80))) case "tool_call": + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "tool_call", Meta: map[string]string{"tool": truncate(chunk.Data, 60)}}) + return + } fmt.Print(gcolor.HEX("#457b9d").Sprintf("\n [Tool: %s]", truncate(chunk.Data, 60))) case "tool_result": - // absorbed into the response + // absorbed into the response — nothing rendered in either mode default: if text := agentExtractText(chunk.Data); text != "" { + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "text", Content: text}) + return + } fmt.Print(text) } } }) - fmt.Println() - fmt.Println() + if !r.out.JSON() { + fmt.Println() + fmt.Println() + } + if streamErrMsg != "" { + return fmt.Errorf("workflow error: %s", streamErrMsg) + } return err }, } @@ -94,6 +136,19 @@ func (r *Registry) runCmd() Command { if len(args) == 0 { return fmt.Errorf("usage: /run ") } + + // Rider B5 — --plan : submit a hand-authored ExecutionPlan + // directly, bypassing NL/template DAG derivation entirely. Checked + // first and handled by a dedicated helper (not folded into the + // --dag branch below) because it must NOT fall back to chat on + // failure the way --dag does — see runPlan's doc comment. + if args[0] == "--plan" { + if len(args) < 2 { + return fmt.Errorf("usage: /run --plan ") + } + return r.runPlan(ctx, args[1]) + } + task := strings.Join(args, " ") // Guard: reject bare command names that look like misrouted slash commands. @@ -222,37 +277,110 @@ func (r *Registry) runCmd() Command { if json.Unmarshal([]byte(chunk.Data), &m) == nil { if e, ok := m["error"].(string); ok && e != "" { streamErrMsg = e + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "error", Content: e}) + } } } return } if text := agentExtractText(chunk.Data); text != "" { - fmt.Print(text) + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "text", Content: text}) + } else { + fmt.Print(text) + } fullText.WriteString(text) } }) - fmt.Println() - fmt.Println() - - if streamErrMsg != "" { - fmt.Println(gcolor.HEX("#ef4444").Sprintf(" [agent error: %s]", truncate(streamErrMsg, 120))) + if !r.out.JSON() { fmt.Println() - } else if fullText.Len() == 0 && err == nil { - fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [no response — the agent may have hit a rate limit or internal error]")) fmt.Println() } + if streamErrMsg != "" { + // Surface the in-band gateway error (already emitted as an NDJSON + // {type:error} line above for the streaming JSON consumer) as the + // command's error, so the headless envelope reports ok=false / + // exit 1 and the REPL renders it via its normal error path — + // instead of the command silently succeeding on a streamed + // failure. + return fmt.Errorf("agent error: %s", streamErrMsg) + } + if fullText.Len() == 0 && err == nil { + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "warning", Content: "no response — the agent may have hit a rate limit or internal error"}) + } else { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [no response — the agent may have hit a rate limit or internal error]")) + fmt.Println() + } + } + return err }, } } +// runPlan implements Rider B5 — "/run --plan ": read file as a +// hand-authored client.ExecutionPlan, submit it, and poll until terminal. +// Deliberately mirrors the --dag/template-match Orchestrate+poll call shape +// above, with one difference: on a submission failure it returns the error +// directly instead of falling through to the ChatStream MVP fallback. --dag's +// fallback makes sense because the DAG was only ever a heuristic guess at the +// user's intent — chat is a reasonable next guess. A plan the user wrote by +// hand is an explicit, deliberate request; silently substituting an unrelated +// chat reply for a submission failure would hide the real problem (a bad plan +// file) behind a confusing, unrelated response. +func (r *Registry) runPlan(ctx context.Context, path string) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("could not read plan file: %w", err) + } + var plan client.ExecutionPlan + if err := json.Unmarshal(raw, &plan); err != nil { + return fmt.Errorf("invalid plan JSON in %s: %w", path, err) + } + + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" Plan: %s (%d nodes)", plan.Name, len(plan.DAG))) + fmt.Println() + + status, err := r.gw.Orchestrate(ctx, client.OrchestrateRequest{ + Plan: plan, + UserID: r.cfg.Auth.UserID, + }) + if err != nil { + return fmt.Errorf("could not submit plan: %w", err) + } + + printKV("execution_id", status.ExecutionID) + printKV("status", colorStatus(status.Status)) + fmt.Println() + + return r.pollDAGUntilTerminal(ctx, status.ExecutionID) +} + +// dagNodeEvent is the NDJSON line shape emitted per polled node status while +// a DAG orchestration runs in headless JSON mode (/run --dag, its +// template-matched sibling, and /run --plan -- all funneled through +// pollDAGUntilTerminal / printDAGNodes). Distinct from streamEvent +// (cmd_agent.go): a DAG node has no "response text", just a discrete +// identity and status an agent branches on structurally, so the shape is +// flat fields instead of {type,content,meta}. +type dagNodeEvent struct { + Stream string `json:"stream"` // "run" -- namespaces this line among any other NDJSON streams a caller interleaves + Event string `json:"event"` // "node" + Node string `json:"node"` + Tool string `json:"tool,omitempty"` + Status string `json:"status"` +} + // pollDAGUntilTerminal polls the orchestration DAG for executionID, printing // node status after every poll, until the DAG reaches a terminal state -// ("completed" or "failed") or a poll fails. It is shared by both /run DAG -// paths (--dag NL-parse and client-side template match), which previously -// duplicated this loop verbatim. +// ("completed" or "failed") or a poll fails. It is shared by all three /run +// DAG paths (--dag NL-parse, client-side template match, and --plan), which +// previously duplicated this loop verbatim. // // On a poll error this prints a message and returns nil — mirroring the // pre-extraction behavior of both call sites, which treated "stop polling" @@ -265,9 +393,17 @@ func (r *Registry) pollDAGUntilTerminal(ctx context.Context, executionID string) dag, pollErr := r.gw.OrchestrationDAG(ctx, executionID) if pollErr != nil { if ctx.Err() != nil { - fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [cancelled]")) + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "cancelled"}) + } else { + fmt.Println(gcolor.HEX("#94a3b8").Sprint(" [cancelled]")) + } } else { - fmt.Println(gcolor.HEX("#ef4444").Sprintf(" poll error: %v", pollErr)) + if r.out.JSON() { + r.out.Emit(streamEvent{Type: "error", Content: pollErr.Error()}) + } else { + fmt.Println(gcolor.HEX("#ef4444").Sprintf(" poll error: %v", pollErr)) + } } return nil } @@ -276,19 +412,34 @@ func (r *Registry) pollDAGUntilTerminal(ctx context.Context, executionID string) fmt.Println() printKV("result", colorStatus(dag.Status)) fmt.Println() + if r.out.JSON() { + // Overrides the "result" Field set by printKV above with the + // full typed DAGStatus (execution_id, status, plan_id, every + // node) -- strictly more useful to an agent than one status + // string, and Data() always wins over accumulated Field()s. + r.out.Data(dag) + } return nil } time.Sleep(800 * time.Millisecond) } } -// printDAGNodes renders DAG node status with icons. +// printDAGNodes renders DAG node status with icons (human mode), or emits +// one dagNodeEvent NDJSON line per node (headless JSON mode) -- so an agent +// polling /run --dag sees structured per-node status without scraping the +// icon table. func (r *Registry) printDAGNodes(nodes []map[string]any) { for _, n := range nodes { id, _ := n["id"].(string) st, _ := n["status"].(string) tool, _ := n["tool_name"].(string) + if r.out.JSON() { + r.out.Emit(dagNodeEvent{Stream: "run", Event: "node", Node: id, Tool: tool, Status: st}) + continue + } + var icon string switch st { case "completed": @@ -375,23 +526,15 @@ func (r *Registry) pilotCmd() Command { // /pilot plain — fallback text mode if len(args) > 0 && args[0] == "plain" { - fmt.Println() - gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Pilot — live event stream (Ctrl+C to stop)")) - fmt.Println() - fmt.Println() - fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" client_id: %s", clientID)) - fmt.Println() + return r.pilotPlain(ctx, clientID) + } - return r.gw.PilotStream(ctx, clientID, func(chunk client.SSEChunk) { - ev := chunk.Event - if ev == "" { - ev = "message" - } - fmt.Printf(" %s %s\n", - gcolor.HEX("#457b9d").Sprintf("%-16s", ev), - gcolor.White.Sprint(truncate(chunk.Data, 100)), - ) - }) + // Headless (JSON dispatch, or any non-interactive run) has no + // terminal to paint an alt-screen TUI into -- route to the same + // plain SSE stream "/pilot plain" uses instead of launching + // Bubbletea, which would hang or corrupt the dispatch. + if r.out.JSON() || r.headless { + return r.pilotPlain(ctx, clientID) } // Default: Bubbletea TUI dashboard @@ -403,6 +546,34 @@ func (r *Registry) pilotCmd() Command { } } +// pilotPlain streams /pilot's live SSE events as plain text (human mode) or +// one NDJSON streamEvent line per event (headless JSON mode, via r.out.Emit). +// Shared by "/pilot plain" and pilotCmd's headless auto-fallback so both +// paths render identically rather than drifting apart as separate copies. +func (r *Registry) pilotPlain(ctx context.Context, clientID string) error { + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Pilot — live event stream (Ctrl+C to stop)")) + fmt.Println() + fmt.Println() + fmt.Println(gcolor.HEX("#94a3b8").Sprintf(" client_id: %s", clientID)) + fmt.Println() + + return r.gw.PilotStream(ctx, clientID, func(chunk client.SSEChunk) { + ev := chunk.Event + if ev == "" { + ev = "message" + } + if r.out.JSON() { + r.out.Emit(streamEvent{Type: ev, Content: chunk.Data}) + return + } + fmt.Printf(" %s %s\n", + gcolor.HEX("#457b9d").Sprintf("%-16s", ev), + gcolor.White.Sprint(truncate(chunk.Data, 100)), + ) + }) +} + // ─── /practice ────────────────────────────────────────────────────────────── func (r *Registry) practiceCmd() Command { @@ -478,13 +649,28 @@ func (r *Registry) toolsCmd() Command { return Command{ Name: "tools", Aliases: []string{"tool"}, - Usage: "/tools [ls]", - Short: "List registered MCP tools", + Usage: "/tools [ls|]", + Short: "List registered MCP tools, or inspect one tool's input schema", Run: func(ctx context.Context, args []string) error { tools, err := r.gw.Tools(ctx) if err != nil { return fmt.Errorf("could not fetch tools: %w", err) } + + // Rider B2 — /tools : any argument other than "ls" names a + // specific tool and shows its input schema (Tool.Parameters, + // already fetched above but previously discarded entirely — args + // was ignored outright). Bare "/tools" and "/tools ls" fall + // through to the grouped listing below, unchanged. + if len(args) > 0 && strings.ToLower(args[0]) != "ls" { + return r.toolSchema(tools, args[0]) + } + + if r.out.JSON() { + r.out.Data(tools) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Tools (%d)\n\n", len(tools))) @@ -520,3 +706,43 @@ func (r *Registry) toolsCmd() Command { }, } } + +// toolSchema implements Rider B2's "/tools " mode: find the named tool +// in an already-fetched list and render its input schema — Tool.Parameters +// (client.go:195), a raw JSON Schema-ish map the grouped listing above never +// showed at all. JSON mode hands back the whole client.Tool (name, +// description, namespace, parameters) via r.out.Data; human mode prints it +// as a small key/value block matching the rest of the command surface. +func (r *Registry) toolSchema(tools []client.Tool, name string) error { + for _, t := range tools { + if t.Name != name { + continue + } + if r.out.JSON() { + r.out.Data(t) + return nil + } + fmt.Println() + gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprintf(" Tool: %s\n\n", t.Name)) + ns := t.Namespace + if ns == "" { + ns = "builtin" + } + printKV("namespace", ns) + printKV("description", t.Description) + if len(t.Parameters) > 0 { + b, jsonErr := json.MarshalIndent(t.Parameters, " ", " ") + if jsonErr == nil { + fmt.Printf("%s\n %s\n", + gcolor.HEX("#94a3b8").Sprintf(" %-24s", "parameters"), + gcolor.White.Sprint(string(b)), + ) + } + } else { + printKV("parameters", gcolor.HEX("#94a3b8").Sprint("(none)")) + } + fmt.Println() + return nil + } + return fmt.Errorf("tool %q not found", name) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 8324732..31871f1 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -2,8 +2,13 @@ package commands import ( + "bytes" "context" + "errors" "fmt" + "io" + "os" + "sort" "strings" "time" @@ -12,10 +17,24 @@ import ( "github.com/DojoGenesis/cli/internal/config" "github.com/DojoGenesis/cli/internal/guardrail" "github.com/DojoGenesis/cli/internal/hooks" + "github.com/DojoGenesis/cli/internal/output" "github.com/DojoGenesis/cli/internal/plugins" gcolor "github.com/gookit/color" ) +// ErrUnknownCommand is the sentinel returned by Dispatch when no command (or +// alias) matches. Headless callers map it to exit code 2 (usage) rather than 1 +// (runtime); errors.Is(err, ErrUnknownCommand) is the check. +var ErrUnknownCommand = errors.New("unknown command") + +// curEmitter is the output sink for the command currently being dispatched +// headlessly. It is nil in the REPL/Human path (so the free formatting helpers +// print exactly as before). RunHeadless sets it for the duration of one +// dispatch and clears it after. Command dispatch is single-goroutine (the REPL +// read loop or the one-shot path), so an unsynchronized package var is safe; +// the Emitter's own state is likewise touched from that one goroutine only. +var curEmitter *output.Emitter + // Registry maps slash command names to handler functions. type Registry struct { cfg *config.Config @@ -36,6 +55,21 @@ type Registry struct { // stdout printer (same surface the REPL prints command errors to); // overridable so tests can capture advice without hijacking os.Stdout. advicePrint func(msg string) + + // out is the structured output sink for the current headless dispatch, or + // nil in the REPL/Human path. Set + cleared by RunHeadless. Command methods + // that want to emit a rich typed payload call r.out.Data(...)/Field(...); + // the free helpers (printKV, colorStatus) consult the package curEmitter. + out *output.Emitter + + // headless is true while a command runs under RunHeadless (no TTY, no + // interactive stdin). Commands that would block on a y/N confirmation call + // r.headlessRefuse(...) to fail cleanly instead of hanging a script. + headless bool + + // assumeYes records explicit --yes consent for this run (set by SetAssumeYes). + // It lets confirmation-gated commands run headlessly without the blunt --yolo. + assumeYes bool } // Command is a callable slash command. @@ -55,7 +89,7 @@ func New(cfg *config.Config, gw *client.Client, plgs []plugins.Plugin, session * gw: gw, cmds: make(map[string]Command), plgs: plgs, - runner: hooks.New(plgs), + runner: hooks.New(plgs, gw), session: session, } r.register() @@ -138,7 +172,7 @@ func (r *Registry) Dispatch(ctx context.Context, input string) error { } } } - return fmt.Errorf("unknown command /%s — type /help for a list", name) + return fmt.Errorf("%w /%s — type /help for a list", ErrUnknownCommand, name) } // guardKey builds the guardrail key for one dispatched command: "cmd:" plus @@ -187,6 +221,184 @@ func (r *Registry) add(cmd Command) { r.cmds[cmd.Name] = cmd } +// ─── Headless / JSON dispatch ─────────────────────────────────────────────── + +// RunHeadless dispatches input in JSON mode and returns the result Envelope. It +// is the non-interactive counterpart to Dispatch: same routing, guardrail, and +// activity logging, but the command's output is captured into a structured +// {ok, command, data, error} envelope instead of streamed as human text. +// +// input is the command line WITHOUT the leading "/", identical to Dispatch. +// +// Mechanism: os.Stdout (and gcolor's writer) are redirected to a pipe for the +// duration of the handler so that any direct fmt.Printf a not-yet-converted +// command emits does not corrupt the JSON stream — it is captured and, if the +// command produced no structured data, surfaced as a {"text": …} fallback. The +// Emitter keeps the real stdout, so a streaming command's Emit(...) NDJSON lines +// bypass the redirect and appear live before this terminal envelope. +func (r *Registry) RunHeadless(ctx context.Context, input string) output.Envelope { + real := os.Stdout + em := output.New(output.JSON, real) + + rp, wp, err := os.Pipe() + if err != nil { + // Can't set up capture — fall back to running without the redirect so + // the command still executes; stray stdout may interleave, but the + // envelope is still emitted. + r.out, curEmitter, r.headless = em, em, true + derr := r.Dispatch(ctx, input) + r.out, curEmitter, r.headless = nil, nil, false + return envelopeFor(input, em, "", derr) + } + + os.Stdout = wp + gcolor.SetOutput(wp) + r.out, curEmitter, r.headless = em, em, true + + // Drain the pipe concurrently so a chatty command cannot deadlock on a full + // pipe buffer while it is still writing. + var buf bytes.Buffer + done := make(chan struct{}) + go func() { _, _ = io.Copy(&buf, rp); close(done) }() + + derr := r.Dispatch(ctx, input) + + _ = wp.Close() + <-done + _ = rp.Close() + os.Stdout = real + gcolor.SetOutput(real) + r.out, curEmitter, r.headless = nil, nil, false + + return envelopeFor(input, em, buf.String(), derr) +} + +// envelopeFor assembles the terminal Envelope from a dispatch outcome. +func envelopeFor(input string, em *output.Emitter, captured string, err error) output.Envelope { + env := output.Envelope{ + Command: commandName(input), + OK: err == nil, + Data: em.Payload(captured), + } + if err != nil { + env.Data = nil + env.Error = &output.Err{Code: errCode(err), Message: cleanErr(err)} + } + return env +} + +// errCode maps an error to a stable, machine-branchable slug. Unknown commands +// are a usage error (exit 2); everything else is a runtime error (exit 1). +func errCode(err error) string { + if errors.Is(err, ErrUnknownCommand) { + return "unknown_command" + } + return "error" +} + +// cleanErr strips the leading "unknown command " sentinel prefix so the +// envelope message reads naturally (the code already carries the class). +func cleanErr(err error) string { + msg := err.Error() + return strings.TrimPrefix(msg, "unknown command ") +} + +// commandName returns the command token (first field, lowercased) from a +// dispatch input, for the envelope's `command` field. Multi-word commands keep +// only the head — e.g. "skill get foo" → "skill". +func commandName(input string) string { + parts := strings.Fields(input) + if len(parts) == 0 { + return "" + } + return strings.ToLower(parts[0]) +} + +// SetAssumeYes records explicit non-interactive consent for the whole run — the +// `--yes` flag. It lets confirmation-gated commands (delete, undo, install, …) +// run headlessly: headlessRefuse then passes, and because the command is past +// that gate in headless mode, autoConfirmed() reports the prompt pre-answered. +// It is scoped per invocation (never persisted), and narrower than --yolo, which +// also skips the permissions gate. +func (r *Registry) SetAssumeYes(b bool) { r.assumeYes = b } + +// SetHeadless marks the registry as running a non-interactive one-shot dispatch. +// RunHeadless sets this for --json runs; the plain (non-JSON) one-shot path calls +// it explicitly so commands take their headless branch — plain-text output, +// refuse stdin prompts — instead of launching an alt-screen TUI or hanging on a +// y/N read as if they were in the REPL. +func (r *Registry) SetHeadless(b bool) { r.headless = b } + +// autoConfirmed reports whether an interactive y/N prompt should be treated as +// already answered "yes" without reading stdin. True in headless mode (a command +// only reaches its prompt in headless mode after headlessRefuse let it past, +// which requires consent), false in the interactive REPL (where the prompt runs +// for real). The canonical guard is `if !r.autoConfirmed() && !confirm() { … }`. +func (r *Registry) autoConfirmed() bool { return r.headless } + +// headlessRefuse returns a non-nil error when a command that needs interactive +// confirmation is invoked headlessly WITHOUT consent. Commands call it before any +// y/N prompt so a scripted caller fails cleanly instead of hanging on stdin. +// Returns nil in the interactive REPL, or headless under explicit consent — +// `--yes` (this command only) or `--yolo` (skip all permission prompts). +func (r *Registry) headlessRefuse(action string) error { + if !r.headless { + return nil + } + if r.assumeYes { + return nil + } + if r.cfg != nil && r.cfg.Permissions.Mode == "yolo" { + return nil + } + return fmt.Errorf("refused: %q needs confirmation — re-run with --yes (or --yolo), or in the REPL", action) +} + +// headlessRefuseStrict is headlessRefuse for commands too destructive for the +// narrower --yes: it requires --yolo (or the REPL). /code undo reverts the whole +// working tree — the kind of scriptable loaded gun that should not fire on a +// per-run --yes a caller might set broadly. --yes alone is refused here. +func (r *Registry) headlessRefuseStrict(action string) error { + if !r.headless { + return nil + } + if r.cfg != nil && r.cfg.Permissions.Mode == "yolo" { + return nil + } + return fmt.Errorf("refused: %q reverts uncommitted changes — re-run with --yolo, or in the REPL (--yes is not enough)", action) +} + +// ─── Discovery ────────────────────────────────────────────────────────────── + +// CommandInfo is the machine-readable descriptor of one command, emitted by +// `dojo --commands`. It is the serializable projection of Command (the Run func +// is omitted). Args/subcommands are not structured — they live in the free-text +// Usage string today. +type CommandInfo struct { + Name string `json:"name"` + Aliases []string `json:"aliases,omitempty"` + Short string `json:"short"` + Usage string `json:"usage"` +} + +// Commands returns every registered command as a CommandInfo, sorted by name — +// the single source of truth for the command catalog (help, completions, and +// `--commands` discovery should all derive from this rather than drift as +// hand-maintained lists). +func (r *Registry) Commands() []CommandInfo { + out := make([]CommandInfo, 0, len(r.cmds)) + for _, c := range r.cmds { + out = append(out, CommandInfo{ + Name: c.Name, + Aliases: c.Aliases, + Short: c.Short, + Usage: c.Usage, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + // ─── Registration ───────────────────────────────────────────────────────────── func (r *Registry) register() { diff --git a/internal/commands/headless_test.go b/internal/commands/headless_test.go new file mode 100644 index 0000000..8e22d14 --- /dev/null +++ b/internal/commands/headless_test.go @@ -0,0 +1,91 @@ +package commands + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/DojoGenesis/cli/internal/client" + "github.com/DojoGenesis/cli/internal/config" +) + +// newHeadlessTestRegistry builds a Registry the way the one-shot path does, with +// a client pointed at a dead URL (the cases here never reach the network). HOME +// is isolated package-wide by TestMain, so config.Load returns safe defaults. +func newHeadlessTestRegistry(t *testing.T) *Registry { + t.Helper() + cfg, err := config.Load() + if err != nil { + t.Fatalf("config.Load: %v", err) + } + gw := client.New(cfg.Gateway.URL, cfg.Gateway.Token, cfg.Gateway.Timeout) + session := "" + return New(cfg, gw, nil, &session) +} + +// An unknown command returns a well-formed error envelope: ok=false, no data, +// error.code="unknown_command" — the signal a headless caller maps to exit 2. +func TestRunHeadlessUnknownCommand(t *testing.T) { + reg := newHeadlessTestRegistry(t) + env := reg.RunHeadless(context.Background(), "definitelynotacommand") + + if env.OK { + t.Fatal("unknown command reported ok=true") + } + if env.Command != "definitelynotacommand" { + t.Fatalf("command = %q", env.Command) + } + if env.Data != nil { + t.Fatalf("error envelope carried data: %v", env.Data) + } + if env.Error == nil || env.Error.Code != "unknown_command" { + t.Fatalf("error = %+v, want code unknown_command", env.Error) + } + + // And the whole envelope round-trips through JSON (what an agent parses). + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var back map[string]any + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if back["ok"] != false { + t.Fatalf("round-trip ok = %v", back["ok"]) + } +} + +// Dispatch still surfaces the sentinel so the human/plain path can map exit +// codes with errors.Is (RunHeadless and main.go share this classification). +func TestDispatchUnknownIsSentinel(t *testing.T) { + reg := newHeadlessTestRegistry(t) + err := reg.Dispatch(context.Background(), "definitelynotacommand") + if !errors.Is(err, ErrUnknownCommand) { + t.Fatalf("err = %v, want wrapped ErrUnknownCommand", err) + } +} + +// Commands() is the discovery source of truth: non-empty, sorted, and every +// entry names a registered command. +func TestCommandsDiscovery(t *testing.T) { + reg := newHeadlessTestRegistry(t) + cmds := reg.Commands() + if len(cmds) == 0 { + t.Fatal("Commands() returned nothing") + } + for i := 1; i < len(cmds); i++ { + if cmds[i-1].Name > cmds[i].Name { + t.Fatalf("Commands() not sorted at %d: %q > %q", i, cmds[i-1].Name, cmds[i].Name) + } + } + for _, c := range cmds { + if c.Name == "" { + t.Fatal("Commands() entry with empty name") + } + if _, ok := reg.cmds[c.Name]; !ok { + t.Fatalf("Commands() lists %q which is not registered", c.Name) + } + } +} diff --git a/internal/commands/project_cmds.go b/internal/commands/project_cmds.go index 471b8e7..93c40a6 100644 --- a/internal/commands/project_cmds.go +++ b/internal/commands/project_cmds.go @@ -29,6 +29,26 @@ import ( // /project track set — update track status // /project decision — record a decision // /project artifact — save an artifact + +// ─── JSON result types (headless mode) ────────────────────────────────────── + +// ProjectStatusResult is the JSON-mode payload for `/project status`. It +// embeds the full project record (tracks, decisions, artifacts, activity +// log) and adds the one derived field — Next — that the human view also +// shows but which isn't itself a stored Project field. +type ProjectStatusResult struct { + *project.Project + Next string `json:"next"` +} + +// ProjectListEntry is one row of the JSON-mode `/project list` payload: the +// stored project record plus whether it's the active one, which (like Next +// above) lives in GlobalState rather than on Project itself. +type ProjectListEntry struct { + *project.Project + Active bool `json:"active"` +} + func (r *Registry) projectCmd() Command { return Command{ Name: "project", @@ -37,7 +57,7 @@ func (r *Registry) projectCmd() Command { Short: "Project lifecycle — phases, tracks, decisions, artifacts", Run: func(ctx context.Context, args []string) error { if len(args) == 0 { - return projectStatus(nil) + return r.projectStatus(nil) } sub := strings.ToLower(args[0]) @@ -47,11 +67,11 @@ func (r *Registry) projectCmd() Command { case "init", "new", "create": return projectInit(rest) case "status", "st": - return projectStatus(rest) + return r.projectStatus(rest) case "switch": return projectSwitch(rest) case "list", "ls": - return projectList(rest) + return r.projectList(rest) case "archive": return projectArchive(rest) case "phase": @@ -108,7 +128,7 @@ func projectInit(args []string) error { // ─── /project status ────────────────────────────────────────────────────────── -func projectStatus(args []string) error { +func (r *Registry) projectStatus(args []string) error { p, err := resolveProjectArg(args) if err != nil { return err @@ -120,6 +140,11 @@ func projectStatus(args []string) error { return nil } + if r.out.JSON() { + r.out.Data(ProjectStatusResult{Project: p, Next: p.SuggestNext()}) + return nil + } + indicator := project.PhaseIndicator[p.Phase] fmt.Println() @@ -244,7 +269,7 @@ func projectSwitch(args []string) error { // ─── /project list ──────────────────────────────────────────────────────────── -func projectList(args []string) error { +func (r *Registry) projectList(args []string) error { includeArchived := false for _, a := range args { if a == "--all" { @@ -262,6 +287,15 @@ func projectList(args []string) error { return fmt.Errorf("project list: load state: %w", err) } + if r.out.JSON() { + entries := make([]ProjectListEntry, len(projects)) + for i, p := range projects { + entries[i] = ProjectListEntry{Project: p, Active: p.ID == gs.ActiveProjectID} + } + r.out.Data(entries) + return nil + } + fmt.Println() gcolor.Bold.Print(gcolor.HEX("#e8b04a").Sprint(" Projects")) fmt.Println() diff --git a/internal/hooks/runner.go b/internal/hooks/runner.go index 8af4d0d..0302f7d 100644 --- a/internal/hooks/runner.go +++ b/internal/hooks/runner.go @@ -17,6 +17,7 @@ import ( "time" "unicode/utf8" + "github.com/DojoGenesis/cli/internal/client" "github.com/DojoGenesis/cli/internal/plugins" ) @@ -35,8 +36,19 @@ const ( type Runner struct { plugins []plugins.Plugin - // warned de-dupes the "hook type X is not implemented" warning for the - // unimplemented prompt/agent types: keys are "\x00", so the + // gw is the gateway client prompt/agent hooks dispatch through: ChatStream + // for "prompt", CreateAgent + AgentChatStream for "agent" (see runHook, + // runPromptHook, runAgentHook). May be nil — every production call site + // (internal/commands.New) always passes a real client, but a bare Runner + // built by hand (tests, or a hypothetical future no-gateway mode) is still + // valid: runPromptHook/runAgentHook guard for nil and fall back to + // warnUnimplemented instead of dereferencing it. Never mutated after New, + // so it's safe to read from the async hook goroutine without a lock. + gw *client.Client + + // warned de-dupes the "hook type X is not implemented" warning that fires + // when a prompt/agent hook runs on a Runner with no gateway client wired + // in (the nil-gw path above): keys are "\x00", so the // warning fires at most once per (plugin, type) for the life of this // Runner rather than on every fire. The REPL builds exactly one Runner per // process (commands.Registry.Runner), so per-Runner is per-process in @@ -45,9 +57,12 @@ type Runner struct { warned sync.Map } -// New creates a Runner from a list of loaded plugins. -func New(ps []plugins.Plugin) *Runner { - return &Runner{plugins: ps} +// New creates a Runner from a list of loaded plugins and the gateway client +// that powers prompt/agent hook dispatch. gw may be nil — see the Runner.gw +// doc comment — in which case prompt/agent hooks fall back to the honest +// warnUnimplemented no-op instead of panicking on a nil client. +func New(ps []plugins.Plugin, gw *client.Client) *Runner { + return &Runner{plugins: ps, gw: gw} } // FireResult is the classified outcome of FireChecked. The zero value means @@ -113,8 +128,12 @@ func (r *Runner) Fire(ctx context.Context, event string, payload map[string]any) // rule runs to completion (ignoring its Async flag — you cannot block on a // fire-and-forget hook) and a non-zero exit returns a Blocked result. An http // hook is fire-and-forget by design (runHTTPHook always succeeds, logging any -// error) and prompt/agent are unimplemented no-ops, so none of those can ever -// block — even inside a Blocking rule. Like the pre-Blocking Fire(), the first +// error), and prompt/agent hooks are advisory-only BY DESIGN (runPromptHook / +// runAgentHook always return nil, whether the gateway call succeeds, fails, +// or the Runner has no gw wired in at all) — so none of those three types can +// ever block, even inside a Blocking rule. This is a locked decision, not a +// gap: an LLM/agent has no natural block signal, and defining one is a +// separate, larger design. Like the pre-Blocking Fire(), the first // command-hook failure short-circuits the remaining rules and hooks. func (r *Runner) FireChecked(ctx context.Context, event string, payload map[string]any) FireResult { for _, p := range r.plugins { @@ -233,14 +252,10 @@ func (r *Runner) runHook(ctx context.Context, h plugins.HookDef, pluginName, plu switch h.Type { case "command": return runCommand(ctx, h.Command, pluginRoot, promptFromPayload(payload)) - case "prompt", "agent": - // prompt/agent dispatch is not implemented. These used to print a - // "[hook:prompt] …" / "[hook:agent] …" label to stdout that looked like - // the hook had done something — a silent no-op wearing a success mask. - // Warn honestly instead, once per (plugin, type) so a hook that fires - // every turn doesn't spam the stream. - r.warnUnimplemented(pluginName, h.Type) - return nil + case "prompt": + return r.runPromptHook(ctx, h, pluginName, payload) + case "agent": + return r.runAgentHook(ctx, h, pluginName, payload) case "http": return runHTTPHook(ctx, h.URL, payload) default: @@ -260,6 +275,149 @@ func (r *Runner) warnUnimplemented(pluginName, hookType string) { fmt.Fprintf(os.Stderr, "[hooks] hook type %q is not implemented; skipping (plugin %s)\n", hookType, pluginName) } +// runPromptHook implements the "prompt" hook type: it sends h.Prompt (after +// renderPrompt's minimal templating) to the gateway's /v1/chat endpoint via +// ChatStream and buffers the streamed response into a log line. Advisory-only +// per the locked design (see FireChecked's doc comment) — success, a gateway +// error, and an empty prompt all return nil here; only a "command" hook can +// ever veto the caller. +func (r *Runner) runPromptHook(ctx context.Context, h plugins.HookDef, pluginName string, payload map[string]any) error { + if r.gw == nil { + // No gateway wired into this Runner (e.g. a bare Runner built by hand + // in a test) — fall back to the same honest no-op warning prompt/agent + // hooks always got, rather than dereferencing a nil client. + r.warnUnimplemented(pluginName, h.Type) + return nil + } + + promptText := renderPrompt(h.Prompt, payload) + if promptText == "" { + log.Printf("[hooks] prompt hook (plugin %s): empty prompt — skipping", pluginName) + return nil + } + + var out strings.Builder + err := r.gw.ChatStream(ctx, client.ChatRequest{Message: promptText, Model: h.Model}, func(chunk client.SSEChunk) { + out.WriteString(extractHookChunkText(chunk.Data)) + }) + if err != nil { + // Advisory: log and move on, never surface as a caller-visible error. + log.Printf("[hooks] prompt hook (plugin %s): gateway error: %v", pluginName, err) + return nil + } + log.Printf("[hooks] prompt hook (plugin %s): %s", pluginName, truncateForLog(strings.TrimSpace(out.String()))) + return nil +} + +// runAgentHook implements the "agent" hook type: it creates a scratch agent +// via CreateAgent, then sends h.Prompt to it via AgentChatStream, buffering +// the streamed response into a log line — same advisory contract as +// runPromptHook (never returns a non-nil error; a "command" hook is the only +// type that can veto the caller). +// +// h.Model is forward-compat only: CreateAgentRequest.Model and +// AgentChatRequest.Model exist on the request types but today's gateway (POST +// /v1/gateway/agents and POST /v1/gateway/agents/:id/chat) has no model field +// and silently ignores it — the same caveat internal/client/client.go already +// documents on ChatRequest.SystemPrompt. Passed through anyway so the day the +// gateway adds support, a hooks.json "model" key starts working with no +// dojo-cli change. +func (r *Runner) runAgentHook(ctx context.Context, h plugins.HookDef, pluginName string, payload map[string]any) error { + if r.gw == nil { + r.warnUnimplemented(pluginName, h.Type) + return nil + } + + promptText := renderPrompt(h.Prompt, payload) + if promptText == "" { + log.Printf("[hooks] agent hook (plugin %s): empty prompt — skipping", pluginName) + return nil + } + + agentResp, err := r.gw.CreateAgent(ctx, client.CreateAgentRequest{Model: h.Model}) + if err != nil { + log.Printf("[hooks] agent hook (plugin %s): create-agent error: %v", pluginName, err) + return nil + } + + var out strings.Builder + err = r.gw.AgentChatStream(ctx, agentResp.AgentID, client.AgentChatRequest{Message: promptText, Model: h.Model}, func(chunk client.SSEChunk) { + out.WriteString(extractHookChunkText(chunk.Data)) + }) + if err != nil { + log.Printf("[hooks] agent hook (plugin %s): gateway error: %v", pluginName, err) + return nil + } + log.Printf("[hooks] agent hook (plugin %s): %s", pluginName, truncateForLog(strings.TrimSpace(out.String()))) + return nil +} + +// renderPrompt returns h.Prompt with two minimal, documented placeholders +// substituted from payload: "{{.command}}" and "{{.prompt}}", matching the +// only two string fields payload ever actually carries (see +// matcherMatches/promptFromPayload — most events pass "command", only +// UserPromptSubmit also carries "prompt"). Deliberately not a text/template +// parse: plain substring substitution over a two-entry, hardcoded allowlist +// covers the whole real payload surface without opening up an arbitrary +// templating contract the data behind it can't back up. A placeholder with no +// matching payload field, or a nil payload, is left verbatim. +func renderPrompt(tmpl string, payload map[string]any) string { + out := tmpl + if payload != nil { + if cmd, ok := payload["command"].(string); ok { + out = strings.ReplaceAll(out, "{{.command}}", cmd) + } + if p, ok := payload["prompt"].(string); ok { + out = strings.ReplaceAll(out, "{{.prompt}}", p) + } + } + return out +} + +// extractHookChunkText pulls the readable text out of one SSE chunk's data +// field for a prompt/agent hook's buffered response. Mirrors the extraction +// the interactive /agent and /run paths already use (agentExtractText in +// internal/commands/cmd_agent.go) — duplicated rather than imported, because +// internal/commands imports internal/hooks (commands.New calls hooks.New) and +// the reverse import would cycle. Gateway chunks are typically +// {"text|content|message|delta": "..."}; a chunk that isn't JSON, or has none +// of those keys, is passed through verbatim so no content is silently +// dropped. +func extractHookChunkText(data string) string { + data = strings.TrimSpace(data) + if data == "" || data == "[DONE]" { + return "" + } + var m map[string]any + if err := json.Unmarshal([]byte(data), &m); err == nil { + for _, key := range []string{"text", "content", "message", "delta"} { + if v, ok := m[key].(string); ok { + return v + } + } + return "" + } + return data +} + +// maxHookLogChars caps how much of a prompt/agent hook's buffered gateway +// response reaches the log. These hooks are advisory — logged for +// visibility, not archived — so an unbounded model response (a paragraph, an +// essay) would otherwise flood stderr every time a chatty hook fires. +const maxHookLogChars = 500 + +// truncateForLog clamps s to maxHookLogChars runes for a hook's log line, +// appending an ellipsis when it does. Rune-based (unlike truncateBytes, used +// for the DOJO_PROMPT env-size cap below) because this is for human/log +// readability, not a hard byte budget. +func truncateForLog(s string) string { + if utf8.RuneCountInString(s) <= maxHookLogChars { + return s + } + r := []rune(s) + return string(r[:maxHookLogChars]) + "…" +} + // promptFromPayload extracts the user prompt an event carries under the // "prompt" key. Only UserPromptSubmit sets it (see the REPL chat path); every // other event's payload lacks the key, so this returns "" and DOJO_PROMPT is diff --git a/internal/hooks/runner_blocking_test.go b/internal/hooks/runner_blocking_test.go index 6530fa9..f2d964d 100644 --- a/internal/hooks/runner_blocking_test.go +++ b/internal/hooks/runner_blocking_test.go @@ -57,7 +57,7 @@ func TestFireChecked_BlockingHookFailure_Blocks(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: "echo denied >&2; exit 3"}}, }}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventPreCommand, map[string]any{"command": "/deploy"}) if !res.Blocked { @@ -96,7 +96,7 @@ func TestFireChecked_BlockingHookSucceeds_NotBlocked(t *testing.T) { Path: tmp, HookRules: []plugins.HookRule{{Event: EventPreCommand, Blocking: true, Hooks: []plugins.HookDef{{Type: "command", Command: "touch " + marker}}}}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventPreCommand, map[string]any{"command": "/ok"}) if res.Blocked || res.Err != nil { @@ -116,7 +116,7 @@ func TestFireChecked_NonBlockingCommandFailure_ContinuesNotBlocked(t *testing.T) Path: t.TempDir(), HookRules: []plugins.HookRule{{Event: EventPreCommand, Blocking: false, Hooks: []plugins.HookDef{{Type: "command", Command: "exit 1"}}}}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventPreCommand, map[string]any{"command": "/x"}) if res.Blocked { @@ -135,7 +135,7 @@ func TestFireChecked_BlockingHTTPHook_CannotBlock(t *testing.T) { Name: "http-block", HookRules: []plugins.HookRule{{Event: EventPreCommand, Blocking: true, Hooks: []plugins.HookDef{{Type: "http", URL: "http://127.0.0.1:0"}}}}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventPreCommand, map[string]any{"command": "/x"}) if res.Blocked { @@ -152,7 +152,7 @@ func TestFireChecked_BlockingCommandHook_IgnoresAsyncAndBlocks(t *testing.T) { Path: t.TempDir(), HookRules: []plugins.HookRule{{Event: EventPreCommand, Blocking: true, Hooks: []plugins.HookDef{{Type: "command", Command: "exit 1", Async: true}}}}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventPreCommand, map[string]any{"command": "/x"}) if !res.Blocked { @@ -178,7 +178,7 @@ func TestFireChecked_UserPromptSubmit_DeliversDojoPromptEnv(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: `printf '%s' "$DOJO_PROMPT" > ` + marker}}, }}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventUserPromptSubmit, map[string]any{"command": "chat", "prompt": prompt}) if res.Blocked || res.Err != nil { @@ -210,7 +210,7 @@ func TestFireChecked_UserPromptSubmit_PromptNotShellInterpolated(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: `printf '%s' "$DOJO_PROMPT" > ` + marker}}, }}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventUserPromptSubmit, map[string]any{"command": "chat", "prompt": prompt}) if res.Blocked || res.Err != nil { @@ -240,7 +240,7 @@ func TestFireChecked_UserPromptSubmit_TruncatesDojoPrompt(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: `printf '%s' "$DOJO_PROMPT" > ` + marker}}, }}, }} - r := New(ps) + r := New(ps, nil) res := r.FireChecked(context.Background(), EventUserPromptSubmit, map[string]any{"command": "chat", "prompt": big}) if res.Blocked || res.Err != nil { @@ -272,7 +272,7 @@ func TestWarnUnimplemented_OncePerPluginType(t *testing.T) { }, }}, }} - r := New(ps) + r := New(ps, nil) out := captureStderr(t, func() { _ = r.Fire(context.Background(), EventPreCommand, nil) diff --git a/internal/hooks/runner_test.go b/internal/hooks/runner_test.go index c61f6bc..d446aa4 100644 --- a/internal/hooks/runner_test.go +++ b/internal/hooks/runner_test.go @@ -3,6 +3,7 @@ package hooks import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -11,6 +12,7 @@ import ( "testing" "time" + "github.com/DojoGenesis/cli/internal/client" "github.com/DojoGenesis/cli/internal/plugins" ) @@ -22,7 +24,7 @@ func TestNew_NilPlugins_DoesNotPanic(t *testing.T) { t.Fatalf("New(nil) panicked: %v", r) } }() - r := New(nil) + r := New(nil, nil) if r == nil { t.Fatal("New(nil) returned nil runner") } @@ -32,7 +34,7 @@ func TestNew_PluginsWithNoHooks_Works(t *testing.T) { ps := []plugins.Plugin{ {Name: "empty-plugin", Version: "1.0", HookRules: nil}, } - r := New(ps) + r := New(ps, nil) if r == nil { t.Fatal("New() returned nil") } @@ -60,7 +62,7 @@ func TestFire_UnknownEvent_NoError(t *testing.T) { }, }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), "NonExistentEvent", nil) if err != nil { t.Errorf("Fire() with unknown event returned unexpected error: %v", err) @@ -94,7 +96,7 @@ func TestFire_CommandHook_ExecutesScript(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventPreCommand, map[string]any{"command": "/help"}) if err != nil { t.Fatalf("Fire() returned error: %v", err) @@ -133,7 +135,7 @@ func TestFire_SessionStartHook_ExecutesScript(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventSessionStart, map[string]any{"session": "dojo-cli-test", "resumed": false}) if err != nil { t.Fatalf("Fire() returned error: %v", err) @@ -173,7 +175,7 @@ func TestFire_AsyncHook_ReturnsBeforeCompletion(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) start := time.Now() err := r.Fire(context.Background(), EventPostCommand, nil) @@ -226,7 +228,7 @@ func TestFire_CancelledContext_AsyncHookNotStarted(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately before firing - r := New(ps) + r := New(ps, nil) err := r.Fire(ctx, EventPreCommand, nil) if err != nil { t.Fatalf("Fire() with cancelled context returned error: %v", err) @@ -260,7 +262,7 @@ func TestFire_NonCommandHooks_Skipped(t *testing.T) { }, }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventPreCommand, nil) if err != nil { t.Errorf("Fire() with non-command hooks returned error: %v", err) @@ -290,7 +292,7 @@ func TestFire_CaseInsensitiveEventMatch(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) // Fire with the canonical constant (different case) err := r.Fire(context.Background(), EventPreCommand, nil) // "PreCommand" if err != nil { @@ -339,7 +341,7 @@ func TestFireHTTPHook(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventPostCommand, payload) if err != nil { t.Fatalf("Fire() returned error: %v", err) @@ -376,7 +378,7 @@ func TestFirePromptHook(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventPreCommand, nil) if err != nil { t.Errorf("Fire() with prompt hook returned error: %v", err) @@ -402,7 +404,7 @@ func TestFireAgentHook(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventPostSkill, nil) if err != nil { t.Errorf("Fire() with agent hook returned error: %v", err) @@ -410,6 +412,111 @@ func TestFireAgentHook(t *testing.T) { // Side effect is stdout output — no assertion needed beyond no error. } +// ─── Prompt/agent hooks with a real gateway client (the load-bearing gap) ──── +// +// The four tests above (TestFire_NonCommandHooks_Skipped, TestFirePromptHook, +// TestFireAgentHook, TestWarnUnimplemented_OncePerPluginType) all build a +// Runner via New(ps, nil) — no gateway wired in — and pin the pre-existing +// warn-and-skip fallback. The two tests below wire a real *client.Client +// backed by an httptest server and prove the hook actually reaches the +// gateway: ChatStream for "prompt", CreateAgent + AgentChatStream for +// "agent" — and that neither can block the caller even from inside a +// Blocking:true rule. + +// TestFirePromptHook_WithGateway_CallsChatStream proves a "prompt" hook with a +// wired gateway client sends h.Prompt (templated via renderPrompt) to +// POST /v1/chat and buffers the streamed response, and that it never blocks +// the caller — even inside a Blocking:true rule, since only "command" hooks +// reach FireChecked's Blocking branch. +func TestFirePromptHook_WithGateway_CallsChatStream(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "data: {\"text\":\"hi from the gateway\"}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + gw := client.New(srv.URL, "", "5s") + ps := []plugins.Plugin{ + { + Name: "prompt-live", + HookRules: []plugins.HookRule{ + { + Event: EventPreCommand, + Blocking: true, // proves a prompt hook can't block even when the rule says Blocking + Hooks: []plugins.HookDef{{Type: "prompt", Prompt: "summarize {{.command}}"}}, + }, + }, + }, + } + r := New(ps, gw) + + res := r.FireChecked(context.Background(), EventPreCommand, map[string]any{"command": "/deploy"}) + if res.Blocked { + t.Fatalf("a prompt hook must never block, even in a Blocking rule: %+v", res) + } + if res.Err != nil { + t.Errorf("FireChecked returned unexpected Err: %v", res.Err) + } + if gotBody == nil { + t.Fatal("gateway never received the /v1/chat request — prompt hook did not call ChatStream") + } + if gotBody["message"] != "summarize /deploy" { + t.Errorf("gateway received message %q, want %q (renderPrompt should have templated {{.command}})", gotBody["message"], "summarize /deploy") + } +} + +// TestFireAgentHook_WithGateway_CreatesAgentAndChats proves an "agent" hook +// with a wired gateway client performs the two-step dispatch — CreateAgent +// then AgentChatStream — and buffers the streamed response. +func TestFireAgentHook_WithGateway_CreatesAgentAndChats(t *testing.T) { + var chatBody map[string]any + mux := http.NewServeMux() + mux.HandleFunc("/v1/gateway/agents", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"agent_id":"agent-123","status":"ready"}`)) + }) + mux.HandleFunc("/v1/gateway/agents/agent-123/chat", func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + _ = json.Unmarshal(body, &chatBody) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "data: {\"content\":\"agent reply\"}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + gw := client.New(srv.URL, "", "5s") + ps := []plugins.Plugin{ + { + Name: "agent-live", + HookRules: []plugins.HookRule{ + { + Event: EventPostSkill, + Hooks: []plugins.HookDef{{Type: "agent", Prompt: "handle this"}}, + }, + }, + }, + } + r := New(ps, gw) + + err := r.Fire(context.Background(), EventPostSkill, nil) + if err != nil { + t.Fatalf("Fire() returned error: %v", err) + } + if chatBody == nil { + t.Fatal("gateway never received the agent chat request — agent hook did not reach AgentChatStream") + } + if chatBody["message"] != "handle this" { + t.Errorf("agent chat received message %q, want %q", chatBody["message"], "handle this") + } +} + // ─── Matcher glob ───────────────────────────────────────────────────────────── func TestMatcherGlob(t *testing.T) { @@ -433,7 +540,7 @@ func TestMatcherGlob(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) // Should match: command starts with "garden" err := r.Fire(context.Background(), EventPreCommand, map[string]any{"command": "/garden ls"}) @@ -480,7 +587,7 @@ func TestIfConditionFalse(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) err := r.Fire(context.Background(), EventPreCommand, nil) if err != nil { t.Fatalf("Fire() returned error: %v", err) @@ -562,7 +669,7 @@ func TestIfConditionEnvVar(t *testing.T) { }, } - r := New(ps) + r := New(ps, nil) // Env var NOT set → hook should not fire. _ = os.Unsetenv(envVar) diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..8bc07ec --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,139 @@ +// Package output is the headless/JSON output seam for dojo slash commands. +// +// Every command handler renders human text straight to stdout and returns only +// an error (see internal/commands.Command). That is fine for the interactive +// REPL, but a non-interactive caller — a script, an agent, CI — needs a +// uniform, machine-parseable result instead of colored human text it has to +// scrape. +// +// The seam works without touching all ~35 command signatures. An *Emitter is +// stashed on the Registry (and in a package-level pointer the free formatting +// helpers consult) for the duration of one headless dispatch. In Human mode the +// Emitter is inert and commands print exactly as before. In JSON mode: +// +// - Field(k, v) / Data(v) accumulate the envelope's `data` payload, +// - Emit(v) writes one NDJSON line to the *real* stdout immediately (for +// streaming commands), and +// - the caller (Registry.RunHeadless) redirects os.Stdout while the handler +// runs, so any stray direct fmt.Printf a not-yet-converted command emits is +// captured as a `data.text` fallback rather than corrupting the JSON stream. +// +// The result of a non-streaming command is one Envelope line: +// +// {"ok":true,"command":"health","data":{...},"error":null} +// {"ok":false,"command":"skill get","data":null,"error":{"code":"not_found","message":"…"}} +package output + +import ( + "encoding/json" + "io" +) + +// Mode selects how a dispatch produces output. +type Mode int + +const ( + // Human renders human text to stdout — the REPL and plain one-shot path. + // In this mode the Emitter is inert (commands print directly as before). + Human Mode = iota + // JSON accumulates a structured payload and emits an Envelope (+ optional + // NDJSON streaming lines) — the headless/agent path. + JSON +) + +// Envelope is the stable per-command result contract emitted in JSON mode. It +// is intentionally small and uniform so an agent writes one parser for the +// whole surface. Data is nil on error; Error is nil on success. +type Envelope struct { + OK bool `json:"ok"` + Command string `json:"command"` + Data any `json:"data"` + Error *Err `json:"error"` +} + +// Err is the structured error carried by a failed Envelope. Code is a stable, +// machine-branchable slug; Message is the human-readable detail. +type Err struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// Emitter is the sink a command writes through during a headless dispatch. +// The zero value is not used; construct with New. All methods are nil-safe so +// the free formatting helpers can call them on a nil package pointer in the +// REPL (Human) path without a guard at every site. +type Emitter struct { + mode Mode + real io.Writer // the real stdout, captured before RunHeadless redirects + enc *json.Encoder // NDJSON encoder over real (one JSON value + newline per line) + data map[string]any // built incrementally via Field + raw any // set wholesale via Data; takes precedence over data +} + +// New builds an Emitter for mode, writing NDJSON streaming lines to real (the +// real stdout, captured before any os.Stdout redirect). +func New(mode Mode, real io.Writer) *Emitter { + return &Emitter{ + mode: mode, + real: real, + enc: json.NewEncoder(real), + data: make(map[string]any), + } +} + +// JSON reports whether this Emitter is accumulating a JSON payload. Nil-safe: +// a nil Emitter (the REPL/Human path) reports false, so callers can branch with +// `if em.JSON() { … structured … } else { … human … }` without a nil check. +func (e *Emitter) JSON() bool { return e != nil && e.mode == JSON } + +// Field records one key/value into the envelope's `data` object. No-op outside +// JSON mode. Later calls with the same key overwrite. Ignored once Data has set +// a wholesale payload. +func (e *Emitter) Field(key string, val any) { + if !e.JSON() { + return + } + e.data[key] = val +} + +// Data sets the entire `data` payload at once (e.g. a typed struct a command +// already fetched). No-op outside JSON mode. Takes precedence over any Field +// entries. The last Data call wins. +func (e *Emitter) Data(v any) { + if !e.JSON() { + return + } + e.raw = v +} + +// Emit writes one NDJSON streaming line to the real stdout immediately — the +// per-event line a streaming command produces before its terminal Envelope. +// No-op outside JSON mode. json.Encoder appends the trailing newline, so each +// call is exactly one NDJSON record. +func (e *Emitter) Emit(v any) { + if !e.JSON() { + return + } + _ = e.enc.Encode(v) +} + +// Payload returns the value for the envelope's `data` field: the wholesale Data +// value if one was set, else the accumulated Field map (when non-empty), else a +// {"text": fallback} object built from stdout captured during the run, else +// nil. This is how a not-yet-converted command still yields usable output +// instead of an empty envelope. +func (e *Emitter) Payload(fallbackText string) any { + if e == nil { + return nil + } + if e.raw != nil { + return e.raw + } + if len(e.data) > 0 { + return e.data + } + if fallbackText != "" { + return map[string]any{"text": fallbackText} + } + return nil +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..23935fd --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,125 @@ +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// Human mode: the Emitter is inert. JSON() is false and the structured methods +// no-op, so a command's normal stdout rendering is untouched. +func TestHumanModeIsInert(t *testing.T) { + var buf bytes.Buffer + e := New(Human, &buf) + if e.JSON() { + t.Fatal("Human emitter reports JSON() true") + } + e.Field("k", "v") + e.Data(map[string]any{"x": 1}) + e.Emit(map[string]any{"stream": "x"}) + if buf.Len() != 0 { + t.Fatalf("Human mode wrote to stream: %q", buf.String()) + } + if got := e.Payload(""); got != nil { + t.Fatalf("Human Payload = %v, want nil", got) + } +} + +// A nil Emitter (the REPL path, where curEmitter is nil) must be safe to call — +// that is what lets the free helpers branch without a guard at every site. +func TestNilEmitterIsSafe(t *testing.T) { + var e *Emitter + if e.JSON() { + t.Fatal("nil emitter reports JSON() true") + } + e.Field("k", "v") // must not panic + e.Data(1) // must not panic + e.Emit(1) // must not panic + if got := e.Payload("x"); got != nil { + t.Fatalf("nil Payload = %v, want nil", got) + } +} + +// Field accumulates the data object in JSON mode. +func TestFieldBuildsData(t *testing.T) { + e := New(JSON, &bytes.Buffer{}) + e.Field("status", "healthy") + e.Field("version", "3.2.2") + got, ok := e.Payload("").(map[string]any) + if !ok { + t.Fatalf("Payload type = %T, want map", e.Payload("")) + } + if got["status"] != "healthy" || got["version"] != "3.2.2" { + t.Fatalf("Payload = %v", got) + } +} + +// Data sets a wholesale payload that takes precedence over Field entries and +// over the captured-text fallback. +func TestDataPrecedence(t *testing.T) { + e := New(JSON, &bytes.Buffer{}) + e.Field("ignored", true) + type row struct { + Name string `json:"name"` + } + e.Data([]row{{Name: "a"}, {Name: "b"}}) + got, ok := e.Payload("captured text").([]row) + if !ok { + t.Fatalf("Payload type = %T, want []row", e.Payload("")) + } + if len(got) != 2 || got[0].Name != "a" { + t.Fatalf("Payload = %v", got) + } +} + +// With neither Data nor Field set, captured stdout becomes a {"text": …} +// fallback so a not-yet-converted command still yields usable output. +func TestTextFallback(t *testing.T) { + e := New(JSON, &bytes.Buffer{}) + got, ok := e.Payload("some human text").(map[string]any) + if !ok || got["text"] != "some human text" { + t.Fatalf("Payload = %v, want text fallback", e.Payload("some human text")) + } + // Empty capture with no structured data → nil (a clean empty envelope). + if e.Payload("") != nil { + t.Fatal("empty Payload should be nil") + } +} + +// Emit writes exactly one NDJSON record (one JSON value + newline) per call to +// the real writer, bypassing any stdout redirect. +func TestEmitWritesNDJSON(t *testing.T) { + var buf bytes.Buffer + e := New(JSON, &buf) + e.Emit(map[string]any{"stream": "run", "node": "plan", "status": "running"}) + e.Emit(map[string]any{"stream": "run", "node": "plan", "status": "ok"}) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("got %d NDJSON lines, want 2: %q", len(lines), buf.String()) + } + var m map[string]any + if err := json.Unmarshal([]byte(lines[1]), &m); err != nil { + t.Fatalf("line 2 not valid JSON: %v", err) + } + if m["status"] != "ok" { + t.Fatalf("line 2 = %v", m) + } +} + +// The Envelope marshals to the stable agent-facing shape. +func TestEnvelopeShape(t *testing.T) { + ok := Envelope{OK: true, Command: "health", Data: map[string]any{"status": "healthy"}} + b, _ := json.Marshal(ok) + if !strings.Contains(string(b), `"ok":true`) || !strings.Contains(string(b), `"error":null`) { + t.Fatalf("ok envelope = %s", b) + } + fail := Envelope{OK: false, Command: "skill", Error: &Err{Code: "not_found", Message: "no"}} + b, _ = json.Marshal(fail) + if !strings.Contains(string(b), `"ok":false`) || !strings.Contains(string(b), `"code":"not_found"`) { + t.Fatalf("fail envelope = %s", b) + } + if !strings.Contains(string(b), `"data":null`) { + t.Fatalf("fail envelope should have data:null: %s", b) + } +} diff --git a/internal/plugins/installer.go b/internal/plugins/installer.go index 5aed9ef..bf6288b 100644 --- a/internal/plugins/installer.go +++ b/internal/plugins/installer.go @@ -3,7 +3,10 @@ package plugins import ( "bufio" + "crypto/sha256" + "encoding/hex" "fmt" + "io/fs" "net/url" "os" "os/exec" @@ -17,6 +20,45 @@ type InstallResult struct { Path string } +// InstallPolicy controls the integrity checks InstallConfirmedWithPolicy +// enforces before (source allowlist) and after (hash pin) cloning an +// external plugin. Installed plugins run arbitrary command-hook shell (see +// internal/hooks), so "eyeball the URL + y/N" is not, by itself, a +// meaningful trust boundary — this is the minimal-but-real check ahead of +// full artifact signing (out of scope here). +type InstallPolicy struct { + // AllowedSources lists "host/owner" origins a plugin's git URL must + // match — e.g. "github.com/DojoGenesis". Matching is case-insensitive + // and exact (no wildcards); see DefaultInstallPolicy for the house + // default. Ignored when AllowAnySource is true. + AllowedSources []string + // AllowAnySource disables the source allowlist entirely. This is a + // separate, explicitly-named opt-out — it is NEVER implied by + // InstallConfirmed's noConfirm/--yes, which only pre-answers the + // interactive y/N prompt. A caller has to deliberately set this field + // to bypass the allowlist. + AllowAnySource bool + // ExpectedSHA256 optionally pins the installed plugin tree to a known + // content digest (see HashPluginTree). Empty skips the pin check. When + // set and a clone yields more than one plugin (the monorepo case), + // EVERY resulting plugin must match — the pin is meant for a single, + // known-good artifact (a root repo or a GitHub subdirectory URL), not + // an "any of these" match across an unrelated set. + ExpectedSHA256 string +} + +// DefaultInstallPolicy returns the house allowlist: first-party DojoGenesis +// and TresPies-source origins only. Callers needing a broader or narrower +// policy build their own InstallPolicy and call InstallConfirmedWithPolicy +// directly; InstallConfirmed (the pre-existing entry point) always applies +// this default so the real /plugin install call path is protected without +// any caller-side change. +func DefaultInstallPolicy() InstallPolicy { + return InstallPolicy{ + AllowedSources: []string{"github.com/DojoGenesis", "github.com/TresPies-source"}, + } +} + // Install clones a git URL and installs one or more plugins into destDir. // Three cases are handled in order: // 1. GitHub subdirectory URL (https://github.com/{o}/{r}/tree/{branch}/{path}) — @@ -40,10 +82,34 @@ func Install(gitURL, destDir string) ([]InstallResult, error) { // It prints a security warning to stderr listing the URL, then — unless // noConfirm is true — prompts the user for explicit y/n confirmation before // proceeding. Pass noConfirm=true to skip the prompt (e.g. --yes flag). +// +// This is now a thin wrapper over InstallConfirmedWithPolicy using +// DefaultInstallPolicy() — additive: the signature and behavior for any +// already-allowlisted source are unchanged, but every call now also passes +// through the source-allowlist gate (Rider C2). Callers that need a +// different policy (a wider allowlist, AllowAnySource, or a sha256 pin) call +// InstallConfirmedWithPolicy directly. func InstallConfirmed(gitURL, destDir string, noConfirm bool) ([]InstallResult, error) { + return InstallConfirmedWithPolicy(gitURL, destDir, noConfirm, DefaultInstallPolicy()) +} + +// InstallConfirmedWithPolicy is InstallConfirmed with an explicit +// InstallPolicy. It prints the same security warning, then enforces the +// policy's source allowlist UNCONDITIONALLY — before the y/N prompt and +// regardless of noConfirm — since noConfirm/--yes only pre-answers the +// confirmation question, never the integrity gate. On success, when +// policy.ExpectedSHA256 is set, every installed result's tree is hashed +// (HashPluginTree) and compared; a mismatch removes everything Install just +// wrote and returns an error rather than leaving a partially-verified +// plugin on disk. +func InstallConfirmedWithPolicy(gitURL, destDir string, noConfirm bool, policy InstallPolicy) ([]InstallResult, error) { fmt.Fprintf(os.Stderr, "\n WARNING: Installing plugin from external source. Verify trust before proceeding.\n") fmt.Fprintf(os.Stderr, " URL: %s\n\n", gitURL) + if err := checkSourceAllowed(gitURL, policy); err != nil { + return nil, err + } + if !noConfirm { fmt.Fprint(os.Stderr, " Continue? [y/N]: ") reader := bufio.NewReader(os.Stdin) @@ -54,7 +120,140 @@ func InstallConfirmed(gitURL, destDir string, noConfirm bool) ([]InstallResult, } } - return Install(gitURL, destDir) + results, err := Install(gitURL, destDir) + if err != nil { + return nil, err + } + + if policy.ExpectedSHA256 != "" { + if err := verifyPinnedHash(results, policy.ExpectedSHA256); err != nil { + for _, res := range results { + _ = os.RemoveAll(res.Path) + } + return nil, err + } + } + + return results, nil +} + +// checkSourceAllowed enforces policy's source allowlist ahead of any clone — +// refusing a non-allowlisted origin here means a malicious or unvetted git +// URL never reaches exec.Command("git", "clone", ...). A URL that parses +// with no host at all (a local filesystem path, e.g. from a dev workflow +// installing a plugin already on disk) is let through: the allowlist exists +// to vet third-party REMOTE origins, and a bare local path carries no +// supply-chain risk of that kind — the caller already has whatever access +// to it that installing would grant. A URL that fails to parse at all is +// refused (fail closed): it cannot be verified as either a safe local path +// or an allowlisted remote origin. +func checkSourceAllowed(gitURL string, policy InstallPolicy) error { + if policy.AllowAnySource { + return nil + } + host, owner, parsed := sourceOrigin(gitURL) + if !parsed { + return fmt.Errorf("plugin install refused: could not parse source %q (set InstallPolicy.AllowAnySource to override)", gitURL) + } + if host == "" { + return nil // local filesystem path — no remote origin to vet + } + candidate := host + if owner != "" { + candidate = host + "/" + owner + } + for _, allowed := range policy.AllowedSources { + if strings.EqualFold(candidate, allowed) { + return nil + } + } + return fmt.Errorf("plugin install refused: source %q is not in the allowlist (%s) — pass an InstallPolicy with AllowAnySource, or an expanded AllowedSources, to override", + candidate, strings.Join(policy.AllowedSources, ", ")) +} + +// sourceOrigin extracts the (host, owner) pair from a plugin git URL for +// allowlist matching, e.g. "https://github.com/DojoGenesis/plugins.git" -> +// ("github.com", "DojoGenesis"). owner is always the first path segment, +// which is correct for both a root repo URL and the +// "/tree/{branch}/{path}" subdirectory form parseGitHubSubdirURL handles — +// the owner sits before "/tree/" either way, so no special-casing of that +// shape is needed here. parsed is false only when normalizeURL's result +// fails to parse as a URL at all (see checkSourceAllowed for how the two +// failure/no-host cases are treated differently). +func sourceOrigin(gitURL string) (host, owner string, parsed bool) { + u, err := url.Parse(normalizeURL(gitURL)) + if err != nil { + return "", "", false + } + if u.Host == "" { + return "", "", true + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) > 0 { + owner = parts[0] + } + return u.Host, owner, true +} + +// HashPluginTree computes a deterministic sha256 digest over a plugin +// directory's file contents and relative paths, for InstallPolicy's +// ExpectedSHA256 pin. filepath.WalkDir visits entries in lexical order, and +// each file's relative path (as a NUL-separated, slash-normalized prefix) is +// hashed ahead of its content, so neither a reordered walk nor a +// same-content-different-name file can produce a matching digest by +// accident. Skips the same VCS/cache cruft the install/copy paths already +// ignore (.git, .DS_Store, __pycache__) so the pin survives incidental +// clone artifacts rather than breaking on them. +func HashPluginTree(dir string) (string, error) { + h := sha256.New() + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if name == ".git" || name == ".DS_Store" || name == "__pycache__" { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + return nil + } + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + return relErr + } + data, readErr := os.ReadFile(path) //nolint:gosec // path walks a just-cloned plugin tree under our own destDir + if readErr != nil { + return readErr + } + // Writing to a hash never errors; discard explicitly for errcheck. + _, _ = fmt.Fprintf(h, "%s\x00", filepath.ToSlash(rel)) + _, _ = h.Write(data) + _, _ = h.Write([]byte{0}) + return nil + }) + if err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// verifyPinnedHash hashes each installed plugin's tree and compares it +// against policy's ExpectedSHA256 pin (case-insensitive hex compare). +func verifyPinnedHash(results []InstallResult, expected string) error { + expected = strings.TrimSpace(expected) + for _, res := range results { + got, err := HashPluginTree(res.Path) + if err != nil { + return fmt.Errorf("hash %s for sha256 pin check: %w", res.Path, err) + } + if !strings.EqualFold(got, expected) { + return fmt.Errorf("sha256 pin mismatch for %s: got %s, want %s", res.Name, got, expected) + } + } + return nil } // Uninstall removes a plugin directory. diff --git a/internal/plugins/installer_policy_test.go b/internal/plugins/installer_policy_test.go new file mode 100644 index 0000000..151e008 --- /dev/null +++ b/internal/plugins/installer_policy_test.go @@ -0,0 +1,215 @@ +package plugins + +// installer_policy_test.go — tests for the Rider C2 integrity gate: +// InstallPolicy's source allowlist (checkSourceAllowed/sourceOrigin) and the +// optional sha256 pin (HashPluginTree/verifyPinnedHash). New file: the +// pre-existing installer_test.go is left untouched. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ─── sourceOrigin / checkSourceAllowed ───────────────────────────────────── + +func TestSourceOrigin(t *testing.T) { + tests := []struct { + name string + input string + wantHost string + wantOwner string + wantParsed bool + }{ + {"github https", "https://github.com/DojoGenesis/plugins.git", "github.com", "DojoGenesis", true}, + {"github bare (no scheme)", "github.com/TresPies-source/foo", "github.com", "TresPies-source", true}, + {"github subdir tree URL", "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/agent-sdk-dev", "github.com", "anthropics", true}, + {"gitlab", "https://gitlab.com/group/project.git", "gitlab.com", "group", true}, + {"local absolute path", "/tmp/some/local/repo.git", "", "", true}, + {"scp-style unparseable", "git@github.com:user/repo.git", "", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + host, owner, parsed := sourceOrigin(tc.input) + if parsed != tc.wantParsed { + t.Fatalf("sourceOrigin(%q) parsed = %v, want %v", tc.input, parsed, tc.wantParsed) + } + if !tc.wantParsed { + return + } + if host != tc.wantHost { + t.Errorf("sourceOrigin(%q) host = %q, want %q", tc.input, host, tc.wantHost) + } + if owner != tc.wantOwner { + t.Errorf("sourceOrigin(%q) owner = %q, want %q", tc.input, owner, tc.wantOwner) + } + }) + } +} + +func TestCheckSourceAllowed_DefaultAllowsHouseOrigins(t *testing.T) { + policy := DefaultInstallPolicy() + for _, u := range []string{ + "https://github.com/DojoGenesis/dojo-ke.git", + "https://github.com/TresPies-source/some-plugin", + "github.com/DojoGenesis/other-repo", + } { + if err := checkSourceAllowed(u, policy); err != nil { + t.Errorf("checkSourceAllowed(%q) = %v, want nil (house origin)", u, err) + } + } +} + +func TestCheckSourceAllowed_DefaultRefusesOtherOrigins(t *testing.T) { + policy := DefaultInstallPolicy() + for _, u := range []string{ + "https://github.com/some-random-org/plugin.git", + "https://gitlab.com/group/project.git", + "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/agent-sdk-dev", + } { + err := checkSourceAllowed(u, policy) + if err == nil { + t.Errorf("checkSourceAllowed(%q) = nil, want a refusal error", u) + continue + } + if !containsAll(err.Error(), "refused", "allowlist") { + t.Errorf("checkSourceAllowed(%q) error = %v, want it to mention refused+allowlist", u, err) + } + } +} + +func TestCheckSourceAllowed_LocalPathAllowed(t *testing.T) { + // A bare local filesystem path has no remote origin to vet — the + // allowlist must not refuse it (this is also what keeps + // TestPermGatePluginInstallAllowlistAllow, in internal/commands, green: + // it installs from a nonexistent local tmp path and expects the + // failure to come from `git clone`, not the allowlist). + policy := DefaultInstallPolicy() + local := filepath.Join(t.TempDir(), "no-such-repo.git") + if err := checkSourceAllowed(local, policy); err != nil { + t.Errorf("checkSourceAllowed(%q) = %v, want nil (local path)", local, err) + } +} + +func TestCheckSourceAllowed_UnparseableRefused(t *testing.T) { + policy := DefaultInstallPolicy() + err := checkSourceAllowed("git@github.com:user/repo.git", policy) + if err == nil { + t.Fatal("expected a refusal for an unparseable source, got nil") + } +} + +func TestCheckSourceAllowed_AllowAnySourceBypasses(t *testing.T) { + policy := InstallPolicy{AllowAnySource: true} + for _, u := range []string{ + "https://github.com/some-random-org/plugin.git", + "git@github.com:user/repo.git", + } { + if err := checkSourceAllowed(u, policy); err != nil { + t.Errorf("checkSourceAllowed(%q) with AllowAnySource = %v, want nil", u, err) + } + } +} + +func TestCheckSourceAllowed_NotBypassedByNoConfirm(t *testing.T) { + // InstallConfirmedWithPolicy must refuse a disallowed origin even with + // noConfirm=true (the --yes path) — noConfirm only pre-answers the y/N + // prompt, never the integrity gate. git is never invoked: if the + // allowlist were bypassed, this would instead fail with a "git clone + // failed" error (or hang) since example.invalid is unreachable. + destDir := t.TempDir() + _, err := InstallConfirmedWithPolicy("https://example.invalid/some-plugin.git", destDir, true, DefaultInstallPolicy()) + if err == nil { + t.Fatal("expected the allowlist to refuse a non-house origin, got nil error") + } + if !containsAll(err.Error(), "refused", "allowlist") { + t.Errorf("error = %v, want an allowlist refusal (not a git/network error)", err) + } + entries, readErr := os.ReadDir(destDir) + if readErr != nil { + t.Fatalf("reading destDir: %v", readErr) + } + if len(entries) != 0 { + t.Errorf("destDir should stay empty after a refused install, has %d entries", len(entries)) + } +} + +// ─── HashPluginTree / verifyPinnedHash ───────────────────────────────────── + +func TestHashPluginTree_DeterministicAndContentSensitive(t *testing.T) { + build := func(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"x"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "skills", "roll"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "skills", "roll", "SKILL.md"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + // VCS cruft that must not affect the digest. + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte("[core]\n"), 0o644); err != nil { + t.Fatal(err) + } + return dir + } + + dirA := build(t, "# roll v1") + dirA2 := build(t, "# roll v1") // identical content, different tmp path + dirB := build(t, "# roll v2 — different content") + + hashA, err := HashPluginTree(dirA) + if err != nil { + t.Fatalf("HashPluginTree(dirA): %v", err) + } + hashA2, err := HashPluginTree(dirA2) + if err != nil { + t.Fatalf("HashPluginTree(dirA2): %v", err) + } + hashB, err := HashPluginTree(dirB) + if err != nil { + t.Fatalf("HashPluginTree(dirB): %v", err) + } + + if hashA != hashA2 { + t.Errorf("identical trees hashed differently: %s vs %s", hashA, hashA2) + } + if hashA == hashB { + t.Errorf("different trees produced the same hash: %s", hashA) + } +} + +func TestVerifyPinnedHash_MismatchDetected(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"x"}`), 0o644); err != nil { + t.Fatal(err) + } + got, err := HashPluginTree(dir) + if err != nil { + t.Fatalf("HashPluginTree: %v", err) + } + + results := []InstallResult{{Name: "x", Path: dir}} + if err := verifyPinnedHash(results, got); err != nil { + t.Errorf("verifyPinnedHash with the correct pin = %v, want nil", err) + } + if err := verifyPinnedHash(results, "0000000000000000000000000000000000000000000000000000000000000000"); err == nil { + t.Error("verifyPinnedHash with a wrong pin = nil, want a mismatch error") + } +} + +// containsAll reports whether s contains every substring in subs. +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +} diff --git a/internal/repl/repl_hooks_test.go b/internal/repl/repl_hooks_test.go index ded22c2..5e4905d 100644 --- a/internal/repl/repl_hooks_test.go +++ b/internal/repl/repl_hooks_test.go @@ -32,7 +32,7 @@ func TestFireUserPromptSubmit_BlockingHookVetoesSend(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: "exit 1"}}, }}, }} - r := &REPL{runner: hooks.New(ps)} + r := &REPL{runner: hooks.New(ps, nil)} if !r.fireUserPromptSubmit(context.Background(), "some message") { t.Error("a failing Blocking UserPromptSubmit hook must veto the send (return true)") @@ -51,7 +51,7 @@ func TestFireUserPromptSubmit_NonBlockingFailureAllowsSend(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: "exit 1"}}, // non-blocking }}, }} - r := &REPL{runner: hooks.New(ps)} + r := &REPL{runner: hooks.New(ps, nil)} if r.fireUserPromptSubmit(context.Background(), "hello") { t.Error("a non-blocking failure must NOT veto the send (return false)") @@ -73,7 +73,7 @@ func TestFireUserPromptSubmit_DeliversPromptToHookEnv(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: `printf '%s' "$DOJO_PROMPT" > ` + marker}}, }}, }} - r := &REPL{runner: hooks.New(ps)} + r := &REPL{runner: hooks.New(ps, nil)} if r.fireUserPromptSubmit(context.Background(), msg) { t.Fatal("a clean hook should not veto the send") @@ -90,7 +90,7 @@ func TestFireUserPromptSubmit_DeliversPromptToHookEnv(t *testing.T) { // TestFireUserPromptSubmit_NoHooks_AllowsSend proves the common case (no // UserPromptSubmit hooks defined) is a silent pass-through. func TestFireUserPromptSubmit_NoHooks_AllowsSend(t *testing.T) { - r := &REPL{runner: hooks.New(nil)} + r := &REPL{runner: hooks.New(nil, nil)} if r.fireUserPromptSubmit(context.Background(), "hi") { t.Error("with no hooks the send must proceed (return false)") } @@ -111,7 +111,7 @@ func TestFireUserPromptSubmit_NonChatMatcherDoesNotFire(t *testing.T) { Hooks: []plugins.HookDef{{Type: "command", Command: "touch " + marker}}, }}, }} - r := &REPL{runner: hooks.New(ps)} + r := &REPL{runner: hooks.New(ps, nil)} if r.fireUserPromptSubmit(context.Background(), "hello") { t.Fatal("a deploy*-scoped rule should not veto") diff --git a/internal/repl/repl_test.go b/internal/repl/repl_test.go index 6a3d2ac..e0b7490 100644 --- a/internal/repl/repl_test.go +++ b/internal/repl/repl_test.go @@ -244,7 +244,7 @@ func TestFireSessionStart_FiresConfiguredHook(t *testing.T) { } r := &REPL{ - runner: hooks.New(ps), + runner: hooks.New(ps, nil), session: "dojo-cli-test-session", resumed: true, } @@ -258,7 +258,7 @@ func TestFireSessionStart_FiresConfiguredHook(t *testing.T) { func TestFireSessionStart_NoMatchingHooks_NoPanic(t *testing.T) { // A REPL whose runner has no SessionStart rules (the common case — most // plugins don't define one) must be a silent no-op, not a panic. - r := &REPL{runner: hooks.New(nil), session: "dojo-cli-test-session"} + r := &REPL{runner: hooks.New(nil, nil), session: "dojo-cli-test-session"} r.fireSessionStart(context.Background()) } @@ -282,7 +282,7 @@ func TestFireSessionEnd_FiresConfiguredHook(t *testing.T) { } r := &REPL{ - runner: hooks.New(ps), + runner: hooks.New(ps, nil), session: "dojo-cli-test-session", } r.fireSessionEnd() @@ -299,7 +299,7 @@ func TestFireSessionEnd_NoMatchingHooks_NoPanic(t *testing.T) { // fires against context.Background() internally (see its doc comment: // this is deliberate so it still runs a hook to completion even when // Run()'s own ctx was already cancelled, e.g. the SIGTERM-shutdown case). - r := &REPL{runner: hooks.New(nil), session: "dojo-cli-test-session"} + r := &REPL{runner: hooks.New(nil, nil), session: "dojo-cli-test-session"} r.fireSessionEnd() } diff --git a/internal/tui/warroom.go b/internal/tui/warroom.go index ab9ddba..3563848 100644 --- a/internal/tui/warroom.go +++ b/internal/tui/warroom.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -117,7 +118,7 @@ type warRoomChatRequest struct { type focusPanel int const ( - focusInput focusPanel = iota + focusInput focusPanel = iota focusScout focusChallenger ) @@ -129,13 +130,13 @@ type WarRoomModel struct { cursorPos int // Two agent panels - scoutBuf *strings.Builder - challengerBuf *strings.Builder - scoutLines []string - challengerLines []string - scoutScroll int - challengerScroll int - scoutStreaming bool + scoutBuf *strings.Builder + challengerBuf *strings.Builder + scoutLines []string + challengerLines []string + scoutScroll int + challengerScroll int + scoutStreaming bool challengerStreaming bool // Streaming channels — nil when no active stream. @@ -688,6 +689,76 @@ func streamAgentToChannel( sendMsg(scoutDoneMsg{}, challengerDoneMsg{}) } +// RunWarRoomHeadless runs the Scout-vs-Challenger debate for one topic without +// the alt-screen TUI — the entry point a non-interactive caller (a script, an +// agent, --json) uses. It launches the same two agent streams the interactive +// model does (streamAgentToChannel, Scout "measured" + Challenger "adversarial"), +// invokes onChunk(agent, text) as each token arrives, and returns both full +// transcripts. onChunk may be nil (e.g. plain-text callers that only want the +// final transcripts). onChunk calls are serialized so a caller writing NDJSON +// straight to stdout never gets two agents' chunks interleaved mid-write. err is +// non-nil if either stream failed. +func RunWarRoomHeadless( + ctx context.Context, + gatewayURL, gatewayToken, model, provider, sessionID, topic string, + onChunk func(agent, text string), +) (scout string, challenger string, err error) { + scoutCh := make(chan tea.Msg, 128) + challengerCh := make(chan tea.Msg, 128) + + go streamAgentToChannel(ctx, gatewayURL, gatewayToken, model, provider, + sessionID+"-measured", topic, scoutSuffix, "measured", true, scoutCh) + go streamAgentToChannel(ctx, gatewayURL, gatewayToken, model, provider, + sessionID+"-adversarial", topic, challengerSuffix, "adversarial", false, challengerCh) + + var mu sync.Mutex // serializes onChunk so NDJSON lines never interleave + var scoutB, challengerB strings.Builder + var scoutErr, challengerErr error + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for msg := range scoutCh { + switch m := msg.(type) { + case scoutChunkMsg: + mu.Lock() + scoutB.WriteString(string(m)) + if onChunk != nil { + onChunk("scout", string(m)) + } + mu.Unlock() + case scoutErrorMsg: + scoutErr = m.err + } + } + }() + go func() { + defer wg.Done() + for msg := range challengerCh { + switch m := msg.(type) { + case challengerChunkMsg: + mu.Lock() + challengerB.WriteString(string(m)) + if onChunk != nil { + onChunk("challenger", string(m)) + } + mu.Unlock() + case challengerErrorMsg: + challengerErr = m.err + } + } + }() + + wg.Wait() + + err = scoutErr + if err == nil { + err = challengerErr + } + return scoutB.String(), challengerB.String(), err +} + // extractWarRoomText pulls readable text from an SSE data field. func extractWarRoomText(event, data string) string { data = strings.TrimSpace(data)