Skip to content

Commit 5c65f48

Browse files
authored
Merge branch 'main' into feat/desktop-control-plane-port
2 parents 1b8df3c + 345b1f7 commit 5c65f48

21 files changed

Lines changed: 1040 additions & 27 deletions

CHANGELOG.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,122 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66

77
<!-- changelog:entries -->
88

9+
## [0.1.114-rc.2] - 2026-07-22
10+
11+
12+
### Added
13+
14+
- Feat(cli): surface the harness golden path (af wait, catalog, golden_path help, JSON envelopes) (#816)
15+
16+
* feat(cli): emit a JSON envelope for af call --async under -o json
17+
18+
`af call --async` printed a bare run-id string on stdout for every output
19+
format, so a harness parsing `-o json` got a non-JSON token. Under an
20+
explicitly requested machine format (-o json/-o yaml) it now emits
21+
{"run_id": "...", "status": "accepted"} so parsers get valid JSON/YAML.
22+
23+
The default and pretty paths keep the bare run-id line that shell scripts
24+
capture via RUN_ID=$(af call node.reasoner --async), so that contract does
25+
not regress.
26+
27+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
28+
29+
* feat(cli): surface the golden path in af agent help
30+
31+
The machine-friendly `af agent help` payload taught discovery and
32+
introspection but never execution: quick_start omitted `af call` and
33+
`af tail` entirely, so a harness could find agents but was never shown how
34+
to run one.
35+
36+
Add a `golden_path` field — an ordered array of {step, command, purpose}
37+
covering the full driving loop (doctor → catalog → install → secrets →
38+
run → ls/discover → call --schema → call --async → wait/tail) — and add
39+
`af call` and `af tail` entries to quick_start.
40+
41+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
42+
43+
* feat(cli): install both AgentField skills when no skill name is given
44+
45+
`af skill install` with no argument resolved to Catalog[0] and installed
46+
only the `agentfield` build skill, never `agentfield-use` — the drive
47+
skill that documents the discover → call → wait loop. A first-time user
48+
therefore never got the golden-loop docs.
49+
50+
Add skillkit.InstallAll, which installs every catalog skill into the
51+
resolved targets, and call it from `af skill install` when no skill name
52+
is passed. Explicit `af skill install <name>` is unchanged. The
53+
interactive picker copy now names both skills (build + drive).
54+
55+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
56+
57+
* feat(cli): add a shared control-plane-unreachable hint
58+
59+
`af call`/`af ls`/`af tail` emitted a raw Go dial error when the control
60+
plane was down, with no guidance — while `af agent` commands already
61+
appended a reachability hint. Every CLI command that talks to the control
62+
plane routes through makeRequest, so wrap a transport-level failure there
63+
with a shared, actionable hint:
64+
65+
Control plane not reachable at <url>. Start it with `af server` or
66+
launch the AgentField desktop app.
67+
68+
A cancelled context (Ctrl-C / caller-handled timeout) is passed through
69+
unwrapped so only genuine connectivity failures get the hint.
70+
71+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
72+
73+
* feat(cli): add af wait to block on an async run
74+
75+
`af call --async` returns a run_id, but there was no first-class way to
76+
block until that run finished — a harness had to poll or tail. Add
77+
`af wait <run_id> [--timeout <sec, default 600>]`: it polls the run
78+
overview (the same /api/v1/agentic/run/:run_id API `af agent run --id`
79+
uses) until every execution is terminal, prints the final status and
80+
result as JSON, and maps outcomes to exit codes — 0 on succeeded, 1 on
81+
failed/cancelled, 2 on timeout.
82+
83+
A 404 (records not yet written after an async accept) is treated as
84+
"not ready" so a freshly-accepted run keeps polling. The command also
85+
exercises the shared control-plane-unreachable hint, covered here by a
86+
cross-command test over call/ls/tail/wait.
87+
88+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
89+
90+
* feat(cli): add af catalog to browse installable agent nodes
91+
92+
There was no CLI way to discover installable nodes — the only curated
93+
catalog lived in the desktop app (desktop/src/shared/catalog.ts). Add
94+
`af catalog`, backed by an in-binary catalog seeded from that same
95+
curated list (name, description, install source, docs URL), so a harness
96+
can browse nodes before `af install` and works offline.
97+
98+
Supports `-o json`/`-o yaml`; the human table ends with the hint
99+
`af install <source>`.
100+
101+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
102+
103+
* test(cli): cover af wait/catalog command paths for the patch gate
104+
105+
Add behavior tests for the previously-untested command wiring and
106+
error paths surfaced by the coverage patch gate:
107+
108+
- af wait: pretty output, invalid-format/empty-id (exit 2), a control
109+
plane 5xx (exit 3), the nil-opts default path, end-to-end command
110+
execution, and rootExecutionResult (explicit root, last-execution
111+
fallback, non-JSON, empty).
112+
- af catalog: end-to-end command execution under -o json.
113+
- af skill install <name>: the explicit-name path stays single-skill.
114+
- makeRequest: a cancelled context is passed through unwrapped, not
115+
relabeled as an unreachable-control-plane error.
116+
117+
Raises control-plane patch coverage back over the 80% floor.
118+
119+
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120+
121+
---------
122+
123+
Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (d6197f0)
124+
9125
## [0.1.114-rc.1] - 2026-07-22
10126

11127

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.114-rc.1
1+
0.1.114-rc.2

control-plane/internal/cli/agent_commands.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,10 +907,29 @@ func agentHelpData() map[string]interface{} {
907907
"af agent discover -q run",
908908
"af agent search \"review pull request\"",
909909
"af agent query -r runs --limit 10",
910+
"af call <node>.<reasoner> --schema",
911+
"af call <node>.<reasoner> --async -o json",
912+
"af wait <run_id> -o json",
913+
"af tail <run_id>",
910914
"af agent run --id <run_id>",
911915
"af agent kb topics",
912916
"af agent kb guide --goal 'build swe agent'",
913917
},
918+
// golden_path is the end-to-end loop a harness follows to go from a cold
919+
// start to a result: discover → install → run → schema → call → wait.
920+
// Each step is ordered and self-contained so an agent can execute them in
921+
// sequence without prior context.
922+
"golden_path": []map[string]interface{}{
923+
{"step": 1, "command": "af doctor", "purpose": "Confirm the CLI can reach a running control plane and inspect the environment"},
924+
{"step": 2, "command": "af catalog -o json", "purpose": "Browse installable agent nodes (name, description, install source)"},
925+
{"step": 3, "command": "af install <source>", "purpose": "Install a node from the catalog, a git URL, or a local path"},
926+
{"step": 4, "command": "af secrets set <KEY> <value>", "purpose": "Provide the API keys the node needs (e.g. model-provider keys)"},
927+
{"step": 5, "command": "af run <node>", "purpose": "Start the node so its reasoners register with the control plane"},
928+
{"step": 6, "command": "af ls # or: af agent discover -q <term>", "purpose": "List the reasoners now available to call"},
929+
{"step": 7, "command": "af call <node>.<reasoner> --schema", "purpose": "Fetch a reasoner's input schema before calling it"},
930+
{"step": 8, "command": "af call <node>.<reasoner> --async -o json", "purpose": "Trigger the reasoner; get {\"run_id\":...,\"status\":\"accepted\"} back as JSON"},
931+
{"step": 9, "command": "af wait <run_id> -o json # or: af tail <run_id>", "purpose": "Block for the final status+result as JSON, or stream progress live"},
932+
},
914933
"auth": map[string]interface{}{
915934
"method": "Set X-API-Key header via --api-key or AGENTFIELD_API_KEY",
916935
"public_endpoints": []string{"GET /api/v1/agentic/kb/topics", "GET /api/v1/agentic/kb/articles", "GET /api/v1/agentic/kb/articles/:article_id", "GET /api/v1/agentic/kb/guide"},

control-plane/internal/cli/agent_helpers_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"os"
99
"path/filepath"
10+
"strings"
1011
"testing"
1112
"time"
1213

@@ -174,6 +175,30 @@ func TestAgentHelpDataShape(t *testing.T) {
174175
require.NoError(t, err)
175176
require.Contains(t, string(raw), `"quick_start"`)
176177
require.Contains(t, string(raw), `"response_schemas"`)
178+
179+
// Contract: quick_start teaches execution, not just introspection — it must
180+
// surface `af call` and `af tail` (the audit found these were omitted).
181+
quickStart, ok := data["quick_start"].([]string)
182+
require.True(t, ok)
183+
joined := strings.Join(quickStart, "\n")
184+
require.Contains(t, joined, "af call")
185+
require.Contains(t, joined, "af tail")
186+
187+
// Contract: golden_path is the ordered driving loop a harness follows.
188+
golden, ok := data["golden_path"].([]map[string]interface{})
189+
require.True(t, ok)
190+
require.GreaterOrEqual(t, len(golden), 8)
191+
var commands []string
192+
for i, step := range golden {
193+
require.Equal(t, i+1, step["step"], "steps must be ordered starting at 1")
194+
require.NotEmpty(t, step["command"])
195+
require.NotEmpty(t, step["purpose"])
196+
commands = append(commands, step["command"].(string))
197+
}
198+
loop := strings.Join(commands, "\n")
199+
for _, want := range []string{"af doctor", "af catalog", "af install", "af secrets set", "af run", "af call", "--schema", "--async", "af wait", "af tail"} {
200+
require.Contains(t, loop, want, "golden_path must cover %q", want)
201+
}
177202
}
178203

179204
func TestListCommandAndLogViewer(t *testing.T) {

control-plane/internal/cli/call.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ func runCall(ctx context.Context, target string, opts *callOptions) error {
128128
if runID == "" {
129129
return cliExitError{Code: 3, Err: fmt.Errorf("server accepted async execution without run_id")}
130130
}
131+
// When a machine format is explicitly requested (-o json/-o yaml), emit a
132+
// structured envelope so a harness parsing stdout gets valid JSON instead
133+
// of a bare token. The default/pretty path keeps the bare run-id line that
134+
// shell scripts capture via `RUN_ID=$(af call node.reasoner --async)`.
135+
if requested := strings.ToLower(strings.TrimSpace(opts.outputFormat)); requested == "json" || requested == "yaml" {
136+
return writeValue(opts.stdout, map[string]interface{}{"run_id": runID, "status": "accepted"}, requested)
137+
}
131138
_, _ = fmt.Fprintln(opts.stdout, runID)
132139
return nil
133140
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
8+
"github.com/Agent-Field/agentfield/control-plane/internal/ui"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// nodeCatalogEntry describes one installable AgentField agent node.
13+
//
14+
// This is deliberately a hand-vetted, in-binary list so `af catalog` works
15+
// offline and gives a harness a curated set of nodes to install before any
16+
// registry search lands. It is seeded from the desktop app's curated list
17+
// (desktop/src/shared/catalog.ts) — keep the two in sync when adding nodes.
18+
// `name` MUST equal the node's agentfield-package.yaml `name:` (the registry
19+
// key after install), which is often not the repo name (SWE-AF → swe-planner).
20+
type nodeCatalogEntry struct {
21+
Name string `json:"name"`
22+
Description string `json:"description"`
23+
Source string `json:"source"` // pass to `af install <source>`
24+
Docs string `json:"docs"` // public repo/docs URL
25+
Language string `json:"language"`
26+
}
27+
28+
// nodeCatalog is the curated set of installable nodes. Mirrors
29+
// desktop/src/shared/catalog.ts. Docs point at the public GitHub repo (the
30+
// `//<subdir>` source selector stripped, since the docs live at the repo root).
31+
var nodeCatalog = []nodeCatalogEntry{
32+
{
33+
Name: "swe-planner",
34+
Description: "Autonomous software-engineering fleet: plan, code, test, and ship production-grade PRs",
35+
Source: "https://github.com/Agent-Field/SWE-AF",
36+
Docs: "https://github.com/Agent-Field/SWE-AF",
37+
Language: "python",
38+
},
39+
{
40+
Name: "swe-planner-go",
41+
Description: "Go port of the SWE fleet: same planning/execution reasoners, one static binary",
42+
Source: "https://github.com/Agent-Field/SWE-AF//go",
43+
Docs: "https://github.com/Agent-Field/SWE-AF",
44+
Language: "go",
45+
},
46+
{
47+
Name: "pr-af",
48+
Description: "Turns a plain task description into a draft pull request on GitHub",
49+
Source: "https://github.com/Agent-Field/pr-af",
50+
Docs: "https://github.com/Agent-Field/pr-af",
51+
Language: "python",
52+
},
53+
{
54+
Name: "pr-af-go",
55+
Description: "Go port of the PR review agent: same reasoners, one static binary",
56+
Source: "https://github.com/Agent-Field/pr-af//go",
57+
Docs: "https://github.com/Agent-Field/pr-af",
58+
Language: "go",
59+
},
60+
{
61+
Name: "sec-af",
62+
Description: "Code security auditor: scans repositories and proves exploitability with verdicts and traces",
63+
Source: "https://github.com/Agent-Field/sec-af",
64+
Docs: "https://github.com/Agent-Field/sec-af",
65+
Language: "python",
66+
},
67+
{
68+
Name: "cloudsecurity-af",
69+
Description: "Cloud security posture: read-only attack-path scans across AWS, GCP, and Azure",
70+
Source: "https://github.com/Agent-Field/cloudsecurity-af",
71+
Docs: "https://github.com/Agent-Field/cloudsecurity-af",
72+
Language: "python",
73+
},
74+
}
75+
76+
// NewCatalogCommand builds `af catalog` — the browse step of the golden path,
77+
// letting a harness discover installable nodes before `af install`.
78+
func NewCatalogCommand() *cobra.Command {
79+
var outputFormat string
80+
cmd := &cobra.Command{
81+
Use: "catalog",
82+
Short: "Browse installable AgentField agent nodes",
83+
Args: cobra.NoArgs,
84+
RunE: func(cmd *cobra.Command, args []string) error {
85+
return runCatalog(os.Stdout, autoOutputFormat(outputFormat, isOutputTerminal()))
86+
},
87+
SilenceUsage: true,
88+
}
89+
cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: pretty, json, yaml")
90+
return cmd
91+
}
92+
93+
func runCatalog(stdout io.Writer, format string) error {
94+
switch format {
95+
case "json", "yaml":
96+
return writeValue(stdout, nodeCatalog, format)
97+
case "pretty":
98+
renderNodeCatalog(stdout)
99+
return nil
100+
default:
101+
return cliExitError{Code: 2, Err: fmt.Errorf("output format must be pretty, json, or yaml")}
102+
}
103+
}
104+
105+
func renderNodeCatalog(stdout io.Writer) {
106+
rows := make([][]string, 0, len(nodeCatalog))
107+
for _, n := range nodeCatalog {
108+
rows = append(rows, []string{n.Name, n.Language, n.Description, n.Source})
109+
}
110+
_, _ = fmt.Fprintln(stdout, ui.Table(
111+
fmt.Sprintf("Installable agent nodes (%d)", len(rows)),
112+
[]string{"NODE", "LANG", "DESCRIPTION", "SOURCE"},
113+
rows,
114+
))
115+
_, _ = fmt.Fprintln(stdout)
116+
_, _ = fmt.Fprintln(stdout, ui.Muted("Install one with: af install <source>"))
117+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestRunCatalogJSON(t *testing.T) {
12+
var stdout bytes.Buffer
13+
require.NoError(t, runCatalog(&stdout, "json"))
14+
15+
var entries []map[string]interface{}
16+
require.NoError(t, json.Unmarshal(stdout.Bytes(), &entries))
17+
require.GreaterOrEqual(t, len(entries), 5, "catalog must list at least five installable nodes")
18+
19+
for _, e := range entries {
20+
require.NotEmpty(t, e["name"], "entry missing name: %v", e)
21+
require.NotEmpty(t, e["description"], "entry %v missing description", e["name"])
22+
require.NotEmpty(t, e["source"], "entry %v missing source", e["name"])
23+
}
24+
}
25+
26+
func TestRunCatalogPrettyEndsWithInstallHint(t *testing.T) {
27+
var stdout bytes.Buffer
28+
require.NoError(t, runCatalog(&stdout, "pretty"))
29+
out := stdout.String()
30+
require.Contains(t, out, "af install <source>")
31+
require.Contains(t, out, "swe-planner")
32+
}
33+
34+
func TestRunCatalogRejectsUnknownFormat(t *testing.T) {
35+
var stdout bytes.Buffer
36+
err := runCatalog(&stdout, "csv")
37+
require.Equal(t, 2, ExitCode(err))
38+
}
39+
40+
func TestNewCatalogCommandExecute(t *testing.T) {
41+
cmd := NewCatalogCommand()
42+
cmd.SetArgs([]string{"-o", "json"})
43+
out := captureOutput(t, func() {
44+
require.NoError(t, cmd.Execute())
45+
})
46+
var entries []map[string]interface{}
47+
require.NoError(t, json.Unmarshal([]byte(out), &entries))
48+
require.GreaterOrEqual(t, len(entries), 5)
49+
}

0 commit comments

Comments
 (0)