Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` — inspect a single tool's input schema (unblocks `/apps call` from blind-guessing JSON)
- feat(run): `/run --plan <file>` — submit a hand-authored `ExecutionPlan` (JSON) to the orchestrator (no NL parsing, no chat fallback)
- feat(memory): `/trail edit <id> <text>` — edit a memory entry (previously only add/rm/search); `/snapshot export <id> [path]` — write a snapshot to a file
- feat(skill): CAS version pinning — `/skill get <name>@<ver>` and `package-all <dir> [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=<hex>` 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
Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Client layer complete:
Command layer (MVP):

- [x] `/run <task>` -- 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

---

Expand Down
110 changes: 107 additions & 3 deletions cmd/dojo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
Expand All @@ -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"
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
//
Expand All @@ -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,
Expand Down Expand Up @@ -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-<nano>). 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)
Expand Down
Loading
Loading