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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,21 @@ raid frontend build # run the "build" command from the frontend repo

Each repository that defines commands appears as a subcommand in `raid --help`. Run `raid <repo> --help` to see available commands for that repo.

### Exit codes

Raid emits categorical exit codes so scripts and agents can branch on the failure class without parsing prose:

| Code | Category | Meaning |
|---|---|---|
| `0` | _success_ | The command completed successfully. |
| `1` | generic | Unclassified failure. |
| `2` | config | Invalid profile / repo / schema. |
| `3` | task | A user task failed. |
| `4` | network | Clone or HTTP failure. |
| `5` | not-found | Profile, repo, env, or command missing. |

Pass `--json` (a persistent flag, works on any subcommand) to receive a structured `{"error": {"code": "...", "category": "...", "message": "...", "hint": "..."}}` document on stderr. See the [errors reference](https://raidcli.dev/docs/references/errors) for the full code table and stability contract.

---

## Configuration
Expand Down
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Raid is written in Go, distributed as a single self-contained binary, and publis

- [Commands reference](https://raidcli.dev/docs/references/commands): Every built-in and user-defined command with flags and behavior
- [Schema reference](https://raidcli.dev/docs/references/schema): Profile and per-repo YAML schemas — every field, type, and default
- [Errors reference](https://raidcli.dev/docs/references/errors): Stable error-code table, exit-code categories (1 generic / 2 config / 3 task / 4 network / 5 not-found), JSON error shape under `--json`

## Schemas

Expand Down
121 changes: 121 additions & 0 deletions site/docs/references/errors.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
sidebar_position: 6
---

# Errors

Every raid failure carries a stable error code, a category that maps to an
exit code, and an optional hint. Codes are part of raid's CLI contract:
new codes ship additively; **existing codes never change name or category
across minor versions**.

## Exit codes

| Code | Category | Meaning |
|---|---|---|
| `0` | _success_ | The command completed successfully. |
| `1` | `generic` | An unclassified failure. Used for raid internal bugs and uncategorised errors. |
| `2` | `config` | A profile, repo, or schema was invalid, missing required fields, or failed validation. |
| `3` | `task` | A user task failed during execution (Shell exit, Script error, …). |
| `4` | `network` | A clone, HTTP download, or other network-bound operation failed. |
| `5` | `not-found` | A referenced profile, repo, environment, or command does not exist. |

Subprocess exits (e.g. a `Shell` task's `cmd: exit 7`) preserve the
subprocess's own exit code rather than mapping into the categories
above. This matches the prior behaviour and means `$?` for `raid <cmd>`
still reflects what the user's script returned.

## Code table

| Code | Category | When you'll see it |
|---|---|---|
| `UNKNOWN` | generic | Any error raid couldn't classify. |
| `INTERNAL` | generic | A raid logic error — file an issue. |
| `GIT_NOT_INSTALLED` | generic | `git` not on PATH. |
| `LOCK_FAILED` | generic | Couldn't acquire `~/.raid/.lock` (another raid process is holding it). |
| `PROFILE_INVALID` | config | A profile failed schema validation. |
| `PROFILE_FILE_READ` | config | Couldn't read or parse a profile file. |
| `PROFILE_ALREADY_EXISTS` | config | `raid profile add` collided with a registered profile. |
| `REPO_INVALID` | config | A repo entry or repo `raid.yaml` is malformed. |
| `CONFIG_INVALID` | config | The root config (`~/.raid/config.toml`) is malformed. |
| `CONFIG_LOAD_FAILED` | config | Couldn't load the root config. |
| `SCHEMA_VALIDATION_FAILED` | config | A JSON Schema check failed. |
| `ARG_INVALID` | config | A CLI argument failed validation. |
| `TASK_FAILED` | task | A task failed during execution (generic). |
| `TASK_SHELL_FAILED` | task | A `Shell` task exited non-zero. |
| `TASK_SCRIPT_FAILED` | task | A `Script` task exited non-zero. |
| `TASK_WAIT_TIMEOUT` | task | A `Wait` task exceeded its timeout. |
| `TASK_TEMPLATE_FAILED` | task | A `Template` task couldn't render or write. |
| `TASK_GIT_FAILED` | task | A `Git` task (non-clone) failed. |
| `CLONE_FAILED` | network | `git clone` returned non-zero. |
| `TASK_HTTP_FAILED` | network | An `HTTP` task failed. |
| `PROFILE_NOT_FOUND` | not-found | The referenced profile is not registered. |
| `PROFILE_NOT_ACTIVE` | not-found | No active profile is set. |
| `PROFILE_FILE_MISSING` | not-found | A registered profile's file is missing on disk. |
| `REPO_NOT_FOUND` | not-found | A repo name isn't in the active profile. |
| `REPO_NOT_CLONED` | not-found | A repo path doesn't exist on disk (run `raid install`). |
| `ENV_NOT_FOUND` | not-found | An environment name isn't declared. |
| `COMMAND_NOT_FOUND` | not-found | `raid <cmd>` referenced an unknown command. |

## JSON shape

`--json` is a persistent flag on `rootCmd`, so it works on any
subcommand. When set, raid emits errors as a single line to stderr in
this shape:

```json
{
"error": {
"code": "REPO_NOT_CLONED",
"category": "not-found",
"message": "repository 'api-gateway' is not cloned at /Users/me/dev/api",
"hint": "Run `raid install` to clone all repos in the active profile.",
"repo": "api-gateway",
"path": "/Users/me/dev/api"
}
}
```

- `code` and `category` are stable contracts. Use them for branching.
- `message` is the same human-readable string that's printed to stderr
in non-JSON mode. Treat it as informational, not stable.
- `hint` is optional. When present it suggests what to try next.
- Additional fields (`repo`, `path`, `task`, …) are code-specific
structured details. They're additive — new fields may appear on a code
in future minor versions without breaking parsers that ignore them.

## MCP integration

When a mutating tool (`raid_install`, `raid_env_switch`, `raid_run_task`)
fails, the MCP server emits the same structured payload as the
`{"error": {...}}` shape above, wrapped with the tool name and any
captured stdout/stderr:

```json
{
"tool": "raid_install",
"code": "CLONE_FAILED",
"category": "network",
"message": "failed to clone repository 'api': exit status 128",
"output": "fatal: Could not read from remote repository.\n",
"repo": "api",
"url": "git@example.com:my-org/api.git"
}
```

This is returned via `mcp.NewToolResultError(...)` so MCP-aware clients
see `isError: true` alongside the parseable payload.

## Stability promise

Error codes are part of raid's public CLI contract:

- A code's **name and category** never change across minor versions.
- Codes are added additively. New codes can appear without breaking
parsers, but existing codes are never repurposed.
- The `message` text is informational and may be reworded.
- New `details` fields can appear on existing codes; old fields are
preserved.
- Breaking changes (renames, category shifts, removals) require a major
version bump and a deprecation period announced in
[What's New](/docs/whats-new).
4 changes: 4 additions & 0 deletions site/docs/whats-new.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ description: Feature-by-feature release notes for Raid.

User-visible changes per release, latest first. For full commit history see the [GitHub releases page](https://github.com/8bitalex/raid/releases).

## 0.13.0 — upcoming

**Structured error output.** Every raid failure now carries a stable `code` (e.g. `PROFILE_NOT_FOUND`, `REPO_NOT_CLONED`, `CLONE_FAILED`, `TASK_SHELL_FAILED`), a category that maps directly to one of five exit codes (`1` generic / `2` config / `3` task / `4` network / `5` not-found), and an optional `hint` explaining what the user can do next. Pass `--json` to any command (including `raid install`, `raid profile add`, custom commands) and errors come back as a single line of `{"error": {"code": "...", "category": "...", "message": "...", "hint": "..."}}` — agents can pivot on the code instead of parsing prose. The `--json` flag itself moved from per-command to a persistent flag on `rootCmd`, so it now works on every command. Error codes are treated as semver-stable: new codes ship additively; existing codes never change name or category across minor versions. See the new [Errors reference](/docs/references/errors) for the full table and JSON shape. Closes [#47](https://github.com/8bitAlex/raid/issues/47).

## 0.12.0 — upcoming

**Repo-as-a-profile for single-repo projects.** `raid profile add ./raid.yaml` now accepts a repo config directly — no wrapping profile file required. Raid detects the repo schema, registers the file as a single-repo profile named after the raid.yaml's `name` field, and `raid profile <name>` activates it like any other profile. Commands, environments, and install tasks defined in the raid.yaml become available at the top level. Existing multi-repo profiles continue to work unchanged. Closes [#52](https://github.com/8bitAlex/raid/issues/52).
Expand Down
11 changes: 5 additions & 6 deletions src/cmd/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,12 @@ import (
"github.com/spf13/cobra"
)

var jsonOutput bool

func init() {
Command.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON output")
}

// Command is the `raid context` subcommand. It prints a condensed snapshot of
// the active workspace — profile, environment, and per-repo branch / dirty
// state — for human or agent consumption, and hosts the `serve` subcommand
// that runs the same data live as an MCP server over stdio.
//
// JSON output is controlled by the persistent --json flag on rootCmd.
var Command = &cobra.Command{
Use: "context",
Short: "Print a condensed summary of the active workspace, or run as an MCP server",
Expand All @@ -31,6 +27,9 @@ var Command = &cobra.Command{
RunE: func(cmd *cobra.Command, _ []string) error {
ws := context.Get()
ws.Tools = collectTools(cmd.Root())
// GetBool returns false (zero value) when the flag isn't
// registered, so this also Just Works for bare test cmds.
jsonOutput, _ := cmd.Root().PersistentFlags().GetBool("json")
if jsonOutput {
return writeJSON(cmd.OutOrStdout(), ws)
}
Expand Down
6 changes: 3 additions & 3 deletions src/cmd/context/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ func TestCommand_isWired(t *testing.T) {
if Command.Use != "context" {
t.Errorf("Use = %q, want %q", Command.Use, "context")
}
if Command.Flags().Lookup("json") == nil {
t.Error("--json flag not registered")
}
// --json moved from a local flag on Command to a persistent flag on
// rootCmd; this command now consults cmd.Root().PersistentFlags() at
// runtime instead of defining its own flag.
}
49 changes: 43 additions & 6 deletions src/cmd/context/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/8bitalex/raid/src/raid"
rctx "github.com/8bitalex/raid/src/raid/context"
"github.com/8bitalex/raid/src/raid/env"
"github.com/8bitalex/raid/src/raid/errs"
"github.com/8bitalex/raid/src/raid/profile"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
Expand Down Expand Up @@ -395,7 +396,7 @@ func handleInstall(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return runErr
})
if lockErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("raid_install: %v\n%s", lockErr, output)), nil
return mcpStructuredError("raid_install", lockErr, output), nil
}

target := "all repos"
Expand All @@ -408,10 +409,10 @@ func handleInstall(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
func handleEnvSwitch(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, err := req.RequireString("env")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("raid_env_switch: %v", err)), nil
return mcpStructuredError("raid_env_switch", errs.ArgInvalid(err.Error()), ""), nil
}
if !env.Contains(name) {
return mcp.NewToolResultError(fmt.Sprintf("raid_env_switch: environment %q not found in active profile", name)), nil
return mcpStructuredError("raid_env_switch", errs.EnvNotFound(name), ""), nil
}

var output string
Expand All @@ -435,15 +436,15 @@ func handleEnvSwitch(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolRe
return runErr
})
if lockErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("raid_env_switch: %v\n%s", lockErr, output)), nil
return mcpStructuredError("raid_env_switch", lockErr, output), nil
}
return mcp.NewToolResultText(fmt.Sprintf("switched to env %q\n%s", name, output)), nil
}

func handleRunTask(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
command, err := req.RequireString("command")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("raid_run_task: %v", err)), nil
return mcpStructuredError("raid_run_task", errs.ArgInvalid(err.Error()), ""), nil
}
args := req.GetStringSlice("args", nil)

Expand All @@ -465,7 +466,7 @@ func handleRunTask(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToolResu
return runErr
})
if lockErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("raid_run_task %q: %v\n%s", command, lockErr, output)), nil
return mcpStructuredError(fmt.Sprintf("raid_run_task %q", command), lockErr, output), nil
}
return mcp.NewToolResultText(fmt.Sprintf("ran %q\n%s", command, output)), nil
}
Expand Down Expand Up @@ -576,6 +577,42 @@ func handleDescribeRepo(_ stdctx.Context, req mcp.CallToolRequest) (*mcp.CallToo
// jsonToolResult marshals payload to indented JSON and wraps it as an MCP
// text-content tool result. Marshal failures produce a tool error rather than
// a protocol error so the model can surface them.
// mcpStructuredError serializes err as JSON so agents can pivot on the
// code / category fields instead of parsing prose. Captured output (stdout
// + stderr from the underlying lib operation) is included as a separate
// field rather than concatenated into the message, so a JSON-aware client
// can split them cleanly while a fallback string view still has both.
func mcpStructuredError(toolName string, err error, output string) *mcp.CallToolResult {
rErr := errs.Wrap(err)
payload := map[string]any{
"tool": toolName,
"code": rErr.Code(),
"category": rErr.Category().String(),
"message": rErr.Error(),
}
if hint := rErr.Hint(); hint != "" {
payload["hint"] = hint
}
for k, v := range rErr.Details() {
switch k {
case "tool", "code", "message", "category", "hint", "output":
continue
}
payload[k] = v
}
if output != "" {
payload["output"] = output
}
data, mErr := json.Marshal(payload)
if mErr != nil {
// Fall back to a plain text result that still names the tool and
// the error — agents shouldn't see UNKNOWN-typed marshaling
// failures hide the real cause.
return mcp.NewToolResultError(fmt.Sprintf("%s: %v\n%s", toolName, err, output))
}
return mcp.NewToolResultError(string(data))
}

func jsonToolResult(payload any) (*mcp.CallToolResult, error) {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
Expand Down
Loading
Loading