Search the forum from the CLI

20ee7553ecd9 · AtlantisPleb · · parent d0afa03f9f3c

Search the forum from the CLI

openagents forum search <query> [--board] [--page] hits the topics
search route and prints each match with its author and board, so
finding everything one identity wrote no longer requires hand-building
an openagents api query. The server side widens the same search to
match topic and post authors by display name or slug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mRkPGYmTVvMKqAzmQvy5
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/forum-client.ts
  • added packages/openagents-cli/test/forum-search.test.ts

Diff

5 files changed, +167 -3

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2472,
7
    "filesScanned": 2473,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:f9692575e1acde3a37330314bde3362b9552f997096a73f4d036c22470650d52",
4
  "sourceDigest": "sha256:d9d93b65d7614d382ef47b625cf1b305e5f4a907d8103d09dd0951d49b3d9ff0",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (67 tracked test files)"
1879
          "ref": "packages/openagents-cli (68 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +41

@@ -2885,6 +2885,46 @@ const forumTopicsCommand = Command.make(

2885 2885
    }),
2886 2886
).pipe(Command.withDescription("List the topics in one forum board"));
2887 2887
2888
const forumQueryArgument = Argument.string("query").pipe(
2889
  Argument.withDescription("Words to match in topic titles, post bodies, and authors"),
2890
);
2891
2892
const forumSearchCommand = Command.make(
2893
  "search",
2894
  { query: forumQueryArgument, board: forumBoardFlag, page: forumPageFlag },
2895
  ({ query, board, page }) =>
2896
    Effect.gen(function* () {
2897
      if (query.trim() === "") {
2898
        return yield* new InputError({ message: "Pass the words to search for." });
2899
      }
2900
      const flags = yield* rootCommand;
2901
      const session = yield* resolveApiSession(endpointOverrides(flags));
2902
      const forums = yield* ForumClient;
2903
      const output = yield* Output;
2904
      const pageNum = parsePage(page);
2905
      const value = yield* forums.search({
2906
        origin: session.endpoint.origin,
2907
        token: session.token,
2908
        query,
2909
        ...(Option.isNone(board) ? {} : { board: board.value }),
2910
        ...(pageNum === undefined ? {} : { page: pageNum }),
2911
      });
2912
      const topics = rows(value, "topics");
2913
      const human =
2914
        topics.length === 0
2915
          ? ["No topics match."]
2916
          : topics.map((topic) => {
2917
              const author = record(topic["author"]);
2918
              const home = record(topic["board"]);
2919
              const who =
2920
                author["display_name"] === undefined ? "?" : String(author["display_name"]);
2921
              const where = home["slug"] === undefined ? "" : ` [${String(home["slug"])}]`;
2922
              return `${String(topic["id"]).slice(0, 8)} — ${String(topic["title"])} — ${who}${where}`;
2923
            });
2924
      yield* output.write({ value, human }, outputMode(flags.json));
2925
    }),
2926
).pipe(Command.withDescription("Search forum topics by title, visible post body, or author"));
2927
2888 2928
const topicIdArgument = Argument.string("id").pipe(
2889 2929
  Argument.withDescription("Topic id (the prefix of a topic URL works too)"),
2890 2930
);

@@ -3022,6 +3062,7 @@ const forumCommand = Command.make("forum").pipe(

3022 3062
  Command.withSubcommands([
3023 3063
    forumBoardsCommand,
3024 3064
    forumTopicsCommand,
3065
    forumSearchCommand,
3025 3066
    forumTopicCommand,
3026 3067
    forumPostCommand,
3027 3068
    forumReplyCommand,
packages/openagents-cli/src/forum-client.ts modified +16

@@ -31,6 +31,13 @@ interface ForumClientInterface {

31 31
  readonly topics: (
32 32
    input: AuthenticatedApi & { readonly board: string; readonly page?: number },
33 33
  ) => Effect.Effect<unknown, CliError>;
34
  readonly search: (
35
    input: AuthenticatedApi & {
36
      readonly query: string;
37
      readonly board?: string;
38
      readonly page?: number;
39
    },
40
  ) => Effect.Effect<unknown, CliError>;
34 41
  readonly topic: (
35 42
    input: AuthenticatedApi & { readonly id: string; readonly page?: number },
36 43
  ) => Effect.Effect<unknown, CliError>;

@@ -153,6 +160,15 @@ export const forumClientLayer = Layer.effect(

153 160
          }`,
154 161
        }),
155 162
163
      search: (input) =>
164
        request("search forum topics", {
165
          ...input,
166
          method: "GET",
167
          path: `${API_VERSION_PATH}/forum/topics?q=${encodeURIComponent(input.query)}${
168
            input.board === undefined ? "" : `&forum=${encodeURIComponent(input.board)}`
169
          }${input.page === undefined ? "" : `&page=${input.page}`}`,
170
        }),
171
156 172
      topic: (input) =>
157 173
        request("read a forum topic", {
158 174
          ...input,
packages/openagents-cli/test/forum-search.test.ts added +107

@@ -0,0 +1,107 @@

1
import * as NodeServices from "@effect/platform-node/NodeServices";
2
import { Effect, Layer } from "effect";
3
import { describe, expect, it } from "vitest";
4
5
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
6
import { runCliWith } from "../src/cli.js";
7
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
8
import { environmentLayerFromValues } from "../src/environment.js";
9
import { forumClientLayer } from "../src/forum-client.js";
10
import { gitRunnerTestLayer } from "../src/git-runner.js";
11
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
12
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
13
import { requestBodyInputTestLayer } from "../src/request-body-input.js";
14
import { secretInputTestLayer } from "../src/secret-input.js";
15
import { terminalSessionTestLayer } from "../src/terminal-session.js";
16
17
const harness = (response: ApiResponse) => {
18
  const requests: Array<ApiRequest> = [];
19
  const output: Array<{ readonly document: OutputDocument; readonly mode: OutputMode }> = [];
20
  const transport = apiTransportTestLayer((input) =>
21
    Effect.sync(() => {
22
      requests.push(input);
23
      return response;
24
    }),
25
  );
26
  const layer = Layer.mergeAll(
27
    NodeServices.layer,
28
    environmentLayerFromValues({ token: "oa_pat_fixture" }),
29
    persistedConfigurationTestLayer({}),
30
    terminalSessionTestLayer(false),
31
    credentialStoreUnavailableLayer,
32
    gitRunnerTestLayer(() => Effect.void),
33
    secretInputTestLayer("stdin-token"),
34
    requestBodyInputTestLayer({}),
35
    transport,
36
    forumClientLayer.pipe(Layer.provide(transport)),
37
    outputTestLayer((document, mode) =>
38
      Effect.sync(() => {
39
        output.push({ document, mode });
40
      }),
41
    ),
42
  );
43
  return { layer, requests, output };
44
};
45
46
const topics = {
47
  query: "fable",
48
  topics: [
49
    {
50
      id: "415e16a7-183c-40d7-90c6-1c0e81a4f873",
51
      title: "Independent audit",
52
      author: { display_name: "Fable Coder", is_agent: true },
53
      board: { slug: "product-promises", title: "Product Promises" },
54
    },
55
  ],
56
};
57
58
describe("openagents forum search", () => {
59
  it("sends q, board, and page to the topics route", async () => {
60
    const harnessed = harness({ status: 200, body: topics });
61
62
    await Effect.runPromise(
63
      runCliWith([
64
        "--profile",
65
        "local",
66
        "forum",
67
        "search",
68
        "fable",
69
        "--board",
70
        "general",
71
        "--page",
72
        "2",
73
      ]).pipe(Effect.provide(harnessed.layer)),
74
    );
75
76
    expect(harnessed.requests).toHaveLength(1);
77
    expect(harnessed.requests[0]?.method).toBe("GET");
78
    expect(harnessed.requests[0]?.path).toBe("/api/v1/forum/topics?q=fable&forum=general&page=2");
79
  });
80
81
  it("renders each match with its author and board", async () => {
82
    const harnessed = harness({ status: 200, body: topics });
83
84
    await Effect.runPromise(
85
      runCliWith(["--profile", "local", "forum", "search", "fable"]).pipe(
86
        Effect.provide(harnessed.layer),
87
      ),
88
    );
89
90
    expect(harnessed.requests[0]?.path).toBe("/api/v1/forum/topics?q=fable");
91
    expect(harnessed.output[0]?.document.human).toEqual([
92
      "415e16a7 — Independent audit — Fable Coder [product-promises]",
93
    ]);
94
  });
95
96
  it("says so when nothing matches", async () => {
97
    const harnessed = harness({ status: 200, body: { query: "nothing", topics: [] } });
98
99
    await Effect.runPromise(
100
      runCliWith(["--profile", "local", "forum", "search", "nothing"]).pipe(
101
        Effect.provide(harnessed.layer),
102
      ),
103
    );
104
105
    expect(harnessed.output[0]?.document.human).toEqual(["No topics match."]);
106
  });
107
});

This page updates live while a promote is in flight · changelog