diff --git a/cmd/hyperguild/README.md b/cmd/hyperguild/README.md index 0984f5a..62d9b9b 100644 --- a/cmd/hyperguild/README.md +++ b/cmd/hyperguild/README.md @@ -55,18 +55,21 @@ Flags: - `--limit N` — max results (default 5) - `--json` — emit the raw response envelope -### `hyperguild brain write ` +### `hyperguild brain write ` -Reads markdown from stdin, writes a knowledge entry. +Reads markdown from stdin, writes it to `brain/wiki///.md` +with `wing`/`hall`/`created_at` frontmatter injected (there is no legacy +`brain/knowledge/` fallback — ADR-0012 / #79, both wing and hall are +required). ```bash -$ cat <` diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go index 9d638e8..484ea03 100644 --- a/cmd/hyperguild/brain.go +++ b/cmd/hyperguild/brain.go @@ -60,13 +60,14 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout, if err := fs.Parse(args); err != nil { return fmt.Errorf("parse flags: %w", err) } - if fs.NArg() < 2 { - return errors.New("type and slug required (e.g. brain write knowledge my-slug)") + if fs.NArg() < 3 { + return errors.New("wing, hall and slug required (e.g. brain write homelab facts my-slug)") } - kind := fs.Arg(0) - slug := fs.Arg(1) + wing := fs.Arg(0) + hall := fs.Arg(1) + slug := fs.Arg(2) - res, err := newBrainClient().Write(ctx, kind, slug, stdin) + res, err := newBrainClient().Write(ctx, wing, hall, slug, stdin) if err != nil { return err } diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go index 74b4062..589f7fd 100644 --- a/cmd/hyperguild/brain_test.go +++ b/cmd/hyperguild/brain_test.go @@ -101,7 +101,7 @@ func TestRunBrainWrite_Success(t *testing.T) { var out, errBuf bytes.Buffer err := runBrain( context.Background(), - []string{"write", "knowledge", "test-slug"}, + []string{"write", "homelab", "facts", "test-slug"}, strings.NewReader("# Test\n\nSome body content.\n"), &out, &errBuf, ) @@ -111,9 +111,9 @@ func TestRunBrainWrite_Success(t *testing.T) { func TestRunBrainWrite_MissingArgs(t *testing.T) { var out, errBuf bytes.Buffer - err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf) + err := runBrain(context.Background(), []string{"write", "homelab"}, strings.NewReader("x"), &out, &errBuf) assert.Error(t, err) - assert.Contains(t, err.Error(), "type and slug required") + assert.Contains(t, err.Error(), "wing, hall and slug required") } func TestRunBrainWrite_BackendError(t *testing.T) { @@ -127,7 +127,7 @@ func TestRunBrainWrite_BackendError(t *testing.T) { var out, errBuf bytes.Buffer err := runBrain( context.Background(), - []string{"write", "knowledge", "bad slug"}, + []string{"write", "homelab", "facts", "bad slug"}, strings.NewReader("body"), &out, &errBuf, ) @@ -150,7 +150,7 @@ func TestRunBrainWrite_EmptyStdin(t *testing.T) { t.Setenv("BRAIN_URL", srv.URL) var out, errBuf bytes.Buffer - err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf) + err := runBrain(context.Background(), []string{"write", "homelab", "facts", "empty"}, strings.NewReader(""), &out, &errBuf) require.NoError(t, err) assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload") } diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go index d3847cb..b6e9b12 100644 --- a/cmd/hyperguild/http.go +++ b/cmd/hyperguild/http.go @@ -84,16 +84,17 @@ type WriteResult struct { Path string `json:"path"` } -func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) { +func (c *brainClient) Write(ctx context.Context, wing, hall, filename string, content io.Reader) (*WriteResult, error) { body, err := io.ReadAll(content) if err != nil { return nil, fmt.Errorf("read content: %w", err) } payload, err := json.Marshal(struct { - Type string `json:"type"` - Slug string `json:"slug"` - Content string `json:"content"` - }{Type: kind, Slug: slug, Content: string(body)}) + Wing string `json:"wing"` + Hall string `json:"hall"` + Filename string `json:"filename"` + Content string `json:"content"` + }{Wing: wing, Hall: hall, Filename: filename, Content: string(body)}) if err != nil { return nil, fmt.Errorf("marshal payload: %w", err) } diff --git a/cmd/hyperguild/http_test.go b/cmd/hyperguild/http_test.go index ffa402a..177a545 100644 --- a/cmd/hyperguild/http_test.go +++ b/cmd/hyperguild/http_test.go @@ -65,23 +65,25 @@ func TestBrainClient_Write_Success(t *testing.T) { assert.Equal(t, http.MethodPost, r.Method) body, _ := io.ReadAll(r.Body) var got struct { - Type string `json:"type"` - Slug string `json:"slug"` - Content string `json:"content"` + Wing string `json:"wing"` + Hall string `json:"hall"` + Filename string `json:"filename"` + Content string `json:"content"` } require.NoError(t, json.Unmarshal(body, &got)) - assert.Equal(t, "knowledge", got.Type) - assert.Equal(t, "find-h", got.Slug) + assert.Equal(t, "homelab", got.Wing) + assert.Equal(t, "facts", got.Hall) + assert.Equal(t, "find-h", got.Filename) assert.Equal(t, "# body\n", got.Content) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`)) + _, _ = w.Write([]byte(`{"path":"wiki/homelab/facts/find-h.md"}`)) })) defer srv.Close() c := &brainClient{baseURL: srv.URL, http: srv.Client()} - res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n")) + res, err := c.Write(context.Background(), "homelab", "facts", "find-h", strings.NewReader("# body\n")) require.NoError(t, err) - assert.Equal(t, "knowledge/find-h.md", res.Path) + assert.Equal(t, "wiki/homelab/facts/find-h.md", res.Path) } func TestBrainClient_PassRate_Success(t *testing.T) { diff --git a/docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md b/docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md index ed04577..96500f3 100644 --- a/docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md +++ b/docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md @@ -16,7 +16,7 @@ Three needs converge on a single small Go binary: - [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output. - [ ] `hyperguild brain query ` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure. -- [ ] `hyperguild brain write ` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/.md`. A round-trip (`hyperguild brain query ` immediately after) finds the entry. +- [ ] `hyperguild brain write ` reads markdown content from stdin, posts to `/write` with wing/hall/filename, and creates `brain/wiki///.md`. A round-trip (`hyperguild brain query ` immediately after) finds the entry. (Updated post-ADR-0012/#79: the legacy ` ` shape and the `brain/knowledge/` fallback were removed; wing and hall are now required.) - [ ] `hyperguild mode ` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output. - [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors. - [ ] `task check` passes (lint + test + vet) on each task and on the merged branch. @@ -50,7 +50,7 @@ Three needs converge on a single small Go binary: - **`brain` subcommand calls the HTTP REST API.** Two nested subcommands: - `brain query ` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption. - - `brain write ` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success. + - `brain write ` reads stdin, posts `POST /write` with JSON body `{wing, hall, filename, content}`, prints the resulting path on success. (Updated post-ADR-0012/#79: wing and hall are required, replacing the old ` ` shape and the `brain/knowledge/` legacy fallback.) Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface. - **`mode ` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out `. Per-mode bodies: diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go index 495f8e9..b5131cd 100644 --- a/ingestion/internal/api/handler_test.go +++ b/ingestion/internal/api/handler_test.go @@ -251,6 +251,50 @@ func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) { assert.True(t, strings.HasSuffix(entries[0].Name(), ".md")) } +// TestWrite_CLIPayloadRoundTrips exercises the real Handler.Write (not a +// hand-rolled mock) over an actual HTTP round trip, POSTing the exact JSON +// envelope cmd/hyperguild's brainClient.Write sends: {wing, hall, filename, +// content}. cmd/hyperguild lives in a separate Go module from ingestion +// (no shared go.work), so a Go-level test can't import brainClient +// directly — this test instead pins the wire contract from the server +// side and proves a real `hyperguild brain write ` +// invocation writes a real file with real frontmatter under wiki///. +// A prior version of this contract test on the client side (in +// cmd/hyperguild/http_test.go) used only an httptest.NewServer with a +// hand-rolled mock body, so it stayed green through a regression where the +// server required wing/hall but the client never sent them (hyperguild#79 +// PR review). +func TestWrite_CLIPayloadRoundTrips(t *testing.T) { + dir, h := setup(t) + srv := httptest.NewServer(http.HandlerFunc(h.Write)) + defer srv.Close() + + payload, err := json.Marshal(struct { + Wing string `json:"wing"` + Hall string `json:"hall"` + Filename string `json:"filename"` + Content string `json:"content"` + }{Wing: "homelab", Hall: "facts", Filename: "cli-roundtrip", Content: "# CLI roundtrip\n\nReal write.\n"}) + require.NoError(t, err) + + resp, err := http.Post(srv.URL+"/write", "application/json", bytes.NewReader(payload)) //nolint:noctx + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var out struct { + Path string `json:"path"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + assert.Equal(t, "wiki/homelab/facts/cli-roundtrip.md", out.Path) + + written, err := os.ReadFile(filepath.Join(dir, "wiki", "homelab", "facts", "cli-roundtrip.md")) + require.NoError(t, err) + assert.Contains(t, string(written), "wing: homelab") + assert.Contains(t, string(written), "hall: facts") + assert.Contains(t, string(written), "Real write.") +} + // --------------------------------------------------------------------------- // POST /ingest // ---------------------------------------------------------------------------