Restore the forum client and commands from their published artifacts

522feac7bc09 · AtlantisPleb · · parent 6556c1db7a94

Restore the forum client and commands from their published artifacts

`@openagentsinc/cli` has shipped a `forum` command since 0.3.0, and its source
was never committed. `packages/openagents-cli/src/forum-client.ts` exists in no
ref on the forge, so nobody could review, patch, or audit the code users
install, and a clean checkout built a CLI missing a command the registry
serves.

The source is not recoverable: neither sourcemap carries `sourcesContent`, and
the machine holds no snapshot, stash, or editor history of it. What survived is
the build output, because `tsc` does not clean its `outDir` — a stale
`dist/forum-client.js` and its emitted `.d.ts`, four minutes newer than the
published 0.3.0 and carrying two error-handling branches that release lacks.

This restores both halves from those artifacts. The client's every request
path, method, body key, and error branch comes from the compiled JavaScript,
and its types come from the emitted declarations. Compiling it reproduces the
recovered artifact byte for byte once comments are stripped, and the emitted
declarations match the recovered ones exactly, so this is the published
behavior rather than an approximation of it. The commands are reconstructed the
same way from the published `cli.js`, and the seven read and write paths were
exercised against production.

The reconstruction closes the review gap. It does not make the package
reproducible: `VERSION` is still a constant that drifts from `package.json`,
and nothing compares a packed artifact to a clean build. Both remain issue
#153.

While here, the coder status bar moves above the composer and gains a right
edge, so the session's state and its location read as one line and the
keybindings sit under the input they act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <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 packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/src/forum-client.ts
  • modified packages/openagents-cli/src/runtime.ts

Diff

4 files changed, +493 -20

packages/openagents-cli/src/cli.ts modified +224

@@ -28,6 +28,7 @@ import { CredentialStore } from "./credential-store.js";

28 28
import { PendingDeviceAuthorizationStore } from "./device-authorization-store.js";
29 29
import { DeviceClient } from "./device-client.js";
30 30
import { type EndpointOverrides, Profile } from "./endpoint.js";
31
import { ForumClient } from "./forum-client.js";
31 32
import { GitRunner } from "./git-runner.js";
32 33
import { runGitCredentialHelper } from "./git-credential-helper.js";
33 34
import { Output, type OutputMode } from "./output.js";

@@ -1147,12 +1148,235 @@ const coderCommand = Command.make(

1147 1148
  ),
1148 1149
);
1149 1150
1151
// The forum commands and their client were published in the CLI but their
1152
// source was never committed. Both are reconstructed from the compiled
1153
// artifacts of that build; see `forum-client.ts` and issue #153.
1154
1155
const forumBoardFlag = Flag.string("board").pipe(
1156
  Flag.optional,
1157
  Flag.withDescription("The board slug, such as general"),
1158
);
1159
const forumPageFlag = Flag.string("page").pipe(
1160
  Flag.optional,
1161
  Flag.withDescription("One-based page number"),
1162
);
1163
1164
/** A page number, or undefined when the flag is absent or not a page. */
1165
const parsePage = (page: Option.Option<string>): number | undefined => {
1166
  if (Option.isNone(page)) return undefined;
1167
  const parsed = Number.parseInt(page.value, 10);
1168
  return Number.isFinite(parsed) && parsed >= 1 ? parsed : undefined;
1169
};
1170
1171
const record = (value: unknown): Record<string, unknown> =>
1172
  value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
1173
const rows = (value: unknown, key: string): ReadonlyArray<Record<string, unknown>> => {
1174
  const list = record(value)[key];
1175
  return Array.isArray(list) ? list.map(record) : [];
1176
};
1177
1178
const forumBoardsCommand = Command.make("boards", {}, () =>
1179
  Effect.gen(function* () {
1180
    const flags = yield* rootCommand;
1181
    const session = yield* resolveApiSession(endpointOverrides(flags));
1182
    const forums = yield* ForumClient;
1183
    const output = yield* Output;
1184
    const value = yield* forums.boards({ origin: session.endpoint.origin, token: session.token });
1185
    const boards = rows(value, "boards");
1186
    const human =
1187
      boards.length === 0
1188
        ? ["No boards found."]
1189
        : boards.map(
1190
            (board) =>
1191
              `${String(board["slug"])} — ${String(board["title"])} (${String(board["topic_count"])} topics)`,
1192
          );
1193
    yield* output.write({ value, human }, outputMode(flags.json));
1194
  }),
1195
).pipe(Command.withDescription("List forum boards"));
1196
1197
const forumTopicsCommand = Command.make(
1198
  "topics",
1199
  { board: forumBoardFlag, page: forumPageFlag },
1200
  ({ board, page }) =>
1201
    Effect.gen(function* () {
1202
      if (Option.isNone(board)) {
1203
        return yield* new InputError({ message: "Pass --board with the board slug." });
1204
      }
1205
      const flags = yield* rootCommand;
1206
      const session = yield* resolveApiSession(endpointOverrides(flags));
1207
      const forums = yield* ForumClient;
1208
      const output = yield* Output;
1209
      const pageNum = parsePage(page);
1210
      const value = yield* forums.topics({
1211
        origin: session.endpoint.origin,
1212
        token: session.token,
1213
        board: board.value,
1214
        ...(pageNum === undefined ? {} : { page: pageNum }),
1215
      });
1216
      const topics = rows(value, "topics");
1217
      const human =
1218
        topics.length === 0
1219
          ? ["No topics found."]
1220
          : topics.map(
1221
              (topic) =>
1222
                `${String(topic["id"]).slice(0, 8)} — ${String(topic["title"])} (${String(topic["posts_count"])} posts)`,
1223
            );
1224
      yield* output.write({ value, human }, outputMode(flags.json));
1225
    }),
1226
).pipe(Command.withDescription("List the topics in one forum board"));
1227
1228
const topicIdArgument = Argument.string("id").pipe(
1229
  Argument.withDescription("Topic id (the prefix of a topic URL works too)"),
1230
);
1231
1232
const forumTopicCommand = Command.make(
1233
  "topic",
1234
  { id: topicIdArgument, page: forumPageFlag },
1235
  ({ id, page }) =>
1236
    Effect.gen(function* () {
1237
      const flags = yield* rootCommand;
1238
      const session = yield* resolveApiSession(endpointOverrides(flags));
1239
      const forums = yield* ForumClient;
1240
      const output = yield* Output;
1241
      const pageNum = parsePage(page);
1242
      const value = yield* forums.topic({
1243
        origin: session.endpoint.origin,
1244
        token: session.token,
1245
        id,
1246
        ...(pageNum === undefined ? {} : { page: pageNum }),
1247
      });
1248
      const topic = record(record(value)["topic"]);
1249
      const human = [
1250
        String(topic["title"] ?? ""),
1251
        ...rows(value, "posts").map((post) => {
1252
          const author = record(post["author"]);
1253
          const name = author["display_name"] === undefined ? "?" : String(author["display_name"]);
1254
          return `#${String(post["post_number"])} ${name}: ${String(post["body_text"] ?? "").slice(0, 120)}`;
1255
        }),
1256
      ];
1257
      yield* output.write({ value, human }, outputMode(flags.json));
1258
    }),
1259
).pipe(Command.withDescription("Read one forum topic and its posts"));
1260
1261
const forumTitleFlag = Flag.string("title").pipe(Flag.withDescription("Topic title"));
1262
const forumBodyFlag = Flag.string("body").pipe(
1263
  Flag.optional,
1264
  Flag.withDescription("Post body text"),
1265
);
1266
1267
const forumPostCommand = Command.make(
1268
  "post",
1269
  { title: forumTitleFlag, body: forumBodyFlag, board: forumBoardFlag },
1270
  ({ title, body, board }) =>
1271
    Effect.gen(function* () {
1272
      if (title.trim() === "") {
1273
        return yield* new InputError({ message: "Pass --title for the new topic." });
1274
      }
1275
      if (Option.isNone(body)) {
1276
        return yield* new InputError({ message: "Pass --body with the first post text." });
1277
      }
1278
      const flags = yield* rootCommand;
1279
      const session = yield* resolveApiSession(endpointOverrides(flags));
1280
      const forums = yield* ForumClient;
1281
      const output = yield* Output;
1282
      const value = yield* forums.createTopic({
1283
        origin: session.endpoint.origin,
1284
        token: session.token,
1285
        board: Option.getOrElse(board, () => "general"),
1286
        title,
1287
        bodyText: body.value,
1288
      });
1289
      const created = record(record(value)["topic"]);
1290
      yield* output.write(
1291
        { value, human: [`Created topic ${String(created["url"] ?? "")}`] },
1292
        outputMode(flags.json),
1293
      );
1294
    }),
1295
).pipe(Command.withDescription("Create a forum topic (--board defaults to general)"));
1296
1297
const topicArgument = Argument.string("topic").pipe(Argument.withDescription("Topic id or URL"));
1298
1299
const forumReplyCommand = Command.make(
1300
  "reply",
1301
  { topic: topicArgument, body: forumBodyFlag },
1302
  ({ topic, body }) =>
1303
    Effect.gen(function* () {
1304
      // A pasted topic URL carries the id; take it rather than making the
1305
      // reader extract it.
1306
      const match = /([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})/i.exec(topic);
1307
      const topicId = match === null ? topic : (match[1] ?? topic);
1308
      if (Option.isNone(body)) {
1309
        return yield* new InputError({ message: "Pass --body with the reply text." });
1310
      }
1311
      const flags = yield* rootCommand;
1312
      const session = yield* resolveApiSession(endpointOverrides(flags));
1313
      const forums = yield* ForumClient;
1314
      const output = yield* Output;
1315
      const value = yield* forums.reply({
1316
        origin: session.endpoint.origin,
1317
        token: session.token,
1318
        topicId,
1319
        bodyText: body.value,
1320
      });
1321
      yield* output.write({ value, human: ["Reply posted."] }, outputMode(flags.json));
1322
    }),
1323
).pipe(Command.withDescription("Reply to a forum topic"));
1324
1325
const actorRefArgument = Argument.string("actor_ref").pipe(
1326
  Argument.withDescription("Legacy identity, such as agent:user_ed8297d8-…"),
1327
);
1328
1329
const forumClaimCommand = Command.make("claim", { actorRef: actorRefArgument }, ({ actorRef }) =>
1330
  Effect.gen(function* () {
1331
    const flags = yield* rootCommand;
1332
    const session = yield* resolveApiSession(endpointOverrides(flags));
1333
    const forums = yield* ForumClient;
1334
    const output = yield* Output;
1335
    const value = yield* forums.claim({
1336
      origin: session.endpoint.origin,
1337
      token: session.token,
1338
      actorRef,
1339
    });
1340
    yield* output.write({ value, human: ["Claim submitted for review."] }, outputMode(flags.json));
1341
  }),
1342
).pipe(Command.withDescription("Claim a legacy forum identity for your account"));
1343
1344
const forumClaimsCommand = Command.make("claims", {}, () =>
1345
  Effect.gen(function* () {
1346
    const flags = yield* rootCommand;
1347
    const session = yield* resolveApiSession(endpointOverrides(flags));
1348
    const forums = yield* ForumClient;
1349
    const output = yield* Output;
1350
    const value = yield* forums.claims({ origin: session.endpoint.origin, token: session.token });
1351
    const claims = rows(value, "claims");
1352
    const human =
1353
      claims.length === 0
1354
        ? ["No claims yet."]
1355
        : claims.map((claim) => `${String(claim["actor_ref"])} — ${String(claim["status"])}`);
1356
    yield* output.write({ value, human }, outputMode(flags.json));
1357
  }),
1358
).pipe(Command.withDescription("List your legacy identity claims"));
1359
1360
const forumCommand = Command.make("forum").pipe(
1361
  Command.withDescription("Read and write the OpenAgents forum"),
1362
  Command.withSubcommands([
1363
    forumBoardsCommand,
1364
    forumTopicsCommand,
1365
    forumTopicCommand,
1366
    forumPostCommand,
1367
    forumReplyCommand,
1368
    forumClaimCommand,
1369
    forumClaimsCommand,
1370
  ]),
1371
);
1372
1150 1373
export const openagentsCommand = rootCommand.pipe(
1151 1374
  Command.withSubcommands([
1152 1375
    apiCommand,
1153 1376
    authCommand,
1154 1377
    coderCommand,
1155 1378
    computerCommand,
1379
    forumCommand,
1156 1380
    repoCommand,
1157 1381
  ]),
1158 1382
);
packages/openagents-cli/src/coder-ui.ts modified +78 -20

@@ -46,6 +46,33 @@ export interface CoderUiOptions {

46 46
  readonly stdout: NodeJS.WriteStream;
47 47
}
48 48
49
/** Visible width, ignoring ANSI styling. */
50
function visibleWidth(text: string): number {
51
  return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length;
52
}
53
54
/**
55
 * Put `left` at the start of a row and `right` at the end.
56
 *
57
 * Padding is computed on visible width so styling does not shift the right
58
 * edge. When the two would collide the right side is dropped rather than
59
 * wrapping the bar onto a second row.
60
 */
61
function justify(left: string, right: string, width: number): string {
62
  const used = visibleWidth(left) + visibleWidth(right);
63
  if (right.length === 0) return left;
64
  if (used + 2 > width) return left;
65
  return left + " ".repeat(width - used) + right;
66
}
67
68
/** Human-readable elapsed time, in the shape a status line wants. */
69
function elapsed(sinceMs: number, nowMs: number): string {
70
  const seconds = Math.max(0, Math.round((nowMs - sinceMs) / 1000));
71
  if (seconds < 60) return `${seconds}s`;
72
  const minutes = Math.floor(seconds / 60);
73
  return `${minutes}m ${seconds % 60}s`;
74
}
75
49 76
/** Wrap one paragraph to the available width, preserving blank lines. */
50 77
function wrap(text: string, width: number): ReadonlyArray<string> {
51 78
  const lines: string[] = [];

@@ -113,6 +140,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

113 140
  let armedNotice = false;
114 141
  let exitCode = 0;
115 142
  let closed = false;
143
  let runningSince = Date.now();
144
  /** Redraws the status line once a second so the elapsed time advances. */
145
  let ticker: NodeJS.Timeout | undefined;
116 146
117 147
  const write = (text: string) => {
118 148
    stdout.write(text);

@@ -123,6 +153,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

123 153
      if (closed) return;
124 154
      closed = true;
125 155
      exitCode = code;
156
      if (ticker !== undefined) {
157
        clearInterval(ticker);
158
        ticker = undefined;
159
      }
126 160
      unsubscribe();
127 161
      stdin.off("data", onData);
128 162
      stdout.off("resize", render);

@@ -184,29 +218,39 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

184 218
        frame.push(`\x1b[${row + 1};1H`, visible[row] ?? "");
185 219
      }
186 220
187
      // Separator, status line, separator.
221
      // Bottom chrome, in the order a reader scans it: what the session is
222
      // doing now, then where the typing goes, then what the keys do. The
223
      // composer sits between two rules so it reads as its own region rather
224
      // than as the last line of the transcript.
188 225
      const rule = "─".repeat(Math.max(0, width));
189
      frame.push(`\x1b[${transcriptHeight + 1};1H`, `${DIM}${rule}${RESET}`);
190
191
      const scrolled = scrollOffset > 0 ? `  ·  scrolled ${scrollOffset}` : "";
192
      const state = snapshot.running ? `${YELLOW}working${RESET}` : `${DIM}ready${RESET}`;
193
      const status =
194
        `  ${snapshot.repository} ${DIM}·${RESET} ${snapshot.branch} ` +
195
        `${DIM}·${RESET} ${snapshot.model} ${DIM}·${RESET} ${snapshot.turns} replies ` +
196
        `${DIM}·${RESET} ${state}${DIM}${scrolled}${RESET}`;
197
      frame.push(`\x1b[${transcriptHeight + 2};1H`, status);
198
      frame.push(`\x1b[${transcriptHeight + 3};1H`, `${DIM}${rule}${RESET}`);
199
200
      const hint = snapshot.running
201
        ? `${DIM}esc esc interrupt${RESET}`
202
        : `${DIM}enter send  ·  ctrl+d quit${RESET}`;
203
      const armed = armedNotice ? `  ${YELLOW}again to interrupt${RESET}` : "";
226
      const inner = Math.max(10, width - 4);
227
228
      const activity = snapshot.running
229
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())} · streaming)${RESET}`
230
        : armedNotice
231
          ? `${YELLOW}●${RESET} ${YELLOW}again to interrupt${RESET}`
232
          : `${DIM}○ ready${RESET}`;
233
      const where = `${DIM}${snapshot.repository} · ${snapshot.branch} · ${snapshot.model}${RESET}`;
234
      frame.push(`\x1b[${transcriptHeight + 1};1H`, `  ${justify(activity, where, inner)}`);
235
236
      frame.push(`\x1b[${transcriptHeight + 2};1H`, `${DIM}${rule}${RESET}`);
237
204 238
      const promptPrefix = "  › ";
205
      frame.push(`\x1b[${transcriptHeight + 4};1H`, `${promptPrefix}${composer}`);
206
      frame.push(`\x1b[${transcriptHeight + 5};1H`, `  ${hint}${armed}`);
239
      frame.push(`\x1b[${transcriptHeight + 3};1H`, `${promptPrefix}${composer}`);
240
241
      frame.push(`\x1b[${transcriptHeight + 4};1H`, `${DIM}${rule}${RESET}`);
242
243
      const keys = snapshot.running
244
        ? `${DIM}esc esc to interrupt · ctrl+c to stop${RESET}`
245
        : `${DIM}enter to send · esc esc to interrupt · ctrl+d to quit${RESET}`;
246
      const counter =
247
        scrollOffset > 0
248
          ? `${DIM}scrolled ${scrollOffset}${RESET}`
249
          : `${DIM}${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"}${RESET}`;
250
      frame.push(`\x1b[${transcriptHeight + 5};1H`, `  ${justify(keys, counter, inner)}`);
207 251
208 252
      // Park the cursor at the composer so typing looks right.
209
      frame.push(`\x1b[${transcriptHeight + 4};${promptPrefix.length + composer.length + 1}H`);
253
      frame.push(`\x1b[${transcriptHeight + 3};${promptPrefix.length + composer.length + 1}H`);
210 254
      frame.push(CURSOR_SHOW);
211 255
      write(frame.join(""));
212 256
    };

@@ -215,8 +259,22 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

215 259
      const prompt = composer;
216 260
      composer = "";
217 261
      scrollOffset = 0;
262
      runningSince = Date.now();
218 263
      render();
219
      void session.submit(prompt);
264
265
      // The elapsed time has to advance between chunks, not only when one
266
      // arrives, or a slow reply looks stalled.
267
      ticker ??= setInterval(() => {
268
        if (session.running) render();
269
      }, 1000);
270
271
      void session.submit(prompt).finally(() => {
272
        if (ticker !== undefined) {
273
          clearInterval(ticker);
274
          ticker = undefined;
275
        }
276
        render();
277
      });
220 278
    };
221 279
222 280
    /**
packages/openagents-cli/src/forum-client.ts added +188

@@ -0,0 +1,188 @@

1
/**
2
 * The forum API client.
3
 *
4
 * Reconstructed from the compiled artifacts of the build that produced the
5
 * published package, because the original source was never committed. The
6
 * behavior here is the published behavior: every request path, method, body
7
 * key, and error branch is taken from `dist/forum-client.js`, and every type is
8
 * taken from the emitted `dist/forum-client.d.ts`. Neither sourcemap carries
9
 * `sourcesContent`, so the original text is not recoverable; this file restores
10
 * what the artifacts prove and nothing more.
11
 *
12
 * See issue #153. The published `@openagentsinc/cli` still cannot be rebuilt
13
 * from committed source until the version constant and the release check land.
14
 */
15
16
import { Effect, Layer, Redacted } from "effect";
17
import * as Context from "effect/Context";
18
19
import { ApiTransport, type ApiRequest } from "./api-transport.js";
20
import { ApiError, type CliError } from "./errors.js";
21
22
/** An origin and the token that authorizes a request against it. */
23
export interface AuthenticatedApi {
24
  readonly origin: string;
25
  readonly token: Redacted.Redacted<string>;
26
}
27
28
interface ForumClientInterface {
29
  readonly boards: (input: AuthenticatedApi) => Effect.Effect<unknown, CliError>;
30
  readonly topics: (
31
    input: AuthenticatedApi & { readonly board: string; readonly page?: number },
32
  ) => Effect.Effect<unknown, CliError>;
33
  readonly topic: (
34
    input: AuthenticatedApi & { readonly id: string; readonly page?: number },
35
  ) => Effect.Effect<unknown, CliError>;
36
  readonly createTopic: (
37
    input: AuthenticatedApi & {
38
      readonly board: string;
39
      readonly title: string;
40
      readonly bodyText: string;
41
    },
42
  ) => Effect.Effect<unknown, CliError>;
43
  readonly reply: (
44
    input: AuthenticatedApi & { readonly topicId: string; readonly bodyText: string },
45
  ) => Effect.Effect<unknown, CliError>;
46
  readonly claim: (
47
    input: AuthenticatedApi & { readonly actorRef: string },
48
  ) => Effect.Effect<unknown, CliError>;
49
  readonly claims: (input: AuthenticatedApi) => Effect.Effect<unknown, CliError>;
50
}
51
52
export class ForumClient extends Context.Service<ForumClient, ForumClientInterface>()(
53
  "@openagentsinc/cli/ForumClient",
54
) {}
55
56
/** Render a field's messages, which the API may send as a list or a string. */
57
const messageList = (value: unknown): string =>
58
  Array.isArray(value)
59
    ? value.filter((item): item is string => typeof item === "string").join(", ")
60
    : typeof value === "string"
61
      ? value
62
      : JSON.stringify(value);
63
64
/**
65
 * Turn a failure body into a code and a message.
66
 *
67
 * The forum routes predate the unified error envelope, so this reads the three
68
 * shapes they actually return: a bare string, `{"error": "..."}`, and a
69
 * field-to-messages map. A body in none of those shapes reports the status
70
 * rather than guessing.
71
 */
72
const errorMessage = (
73
  body: unknown,
74
  status: number,
75
): { readonly code?: string; readonly message: string } => {
76
  if (typeof body === "string" && body.trim() !== "") {
77
    return { message: body.trim().slice(0, 200) };
78
  }
79
80
  if (body !== null && typeof body === "object") {
81
    const record = body as Record<string, unknown>;
82
83
    if (typeof record.error === "string") {
84
      return { code: record.error, message: record.error };
85
    }
86
87
    const errors = record.errors;
88
    if (errors !== null && typeof errors === "object") {
89
      return {
90
        message: Object.entries(errors as Record<string, unknown>)
91
          .map(([field, messages]) => `${field} ${messageList(messages)}`)
92
          .join("; "),
93
      };
94
    }
95
  }
96
97
  return { message: `The forum API returned HTTP ${status}.` };
98
};
99
100
export const forumClientLayer = Layer.effect(
101
  ForumClient,
102
  Effect.gen(function* () {
103
    const transport = yield* ApiTransport;
104
105
    const request = (
106
      operation: string,
107
      input: AuthenticatedApi & {
108
        readonly method: ApiRequest["method"];
109
        readonly path: string;
110
        readonly body?: unknown;
111
      },
112
    ): Effect.Effect<unknown, CliError> =>
113
      Effect.gen(function* () {
114
        const response = yield* transport.request({
115
          origin: input.origin,
116
          method: input.method,
117
          path: input.path,
118
          token: input.token,
119
          ...(input.body === undefined ? {} : { body: input.body }),
120
        });
121
122
        if (response.status < 200 || response.status >= 300) {
123
          const details = errorMessage(response.body, response.status);
124
          return yield* new ApiError({
125
            operation,
126
            status: response.status,
127
            ...(details.code === undefined ? {} : { code: details.code }),
128
            message: details.message,
129
            ...(response.requestId === undefined ? {} : { requestId: response.requestId }),
130
          });
131
        }
132
133
        return response.body;
134
      });
135
136
    return {
137
      request,
138
139
      boards: (input) =>
140
        request("list forum boards", { ...input, method: "GET", path: "/api/v3/forum" }),
141
142
      topics: (input) =>
143
        request("list forum topics", {
144
          ...input,
145
          method: "GET",
146
          path: `/api/v3/forum/topics?forum=${encodeURIComponent(input.board)}${
147
            input.page === undefined ? "" : `&page=${input.page}`
148
          }`,
149
        }),
150
151
      topic: (input) =>
152
        request("read a forum topic", {
153
          ...input,
154
          method: "GET",
155
          path: `/api/v3/forum/topics/${encodeURIComponent(input.id)}${
156
            input.page === undefined ? "" : `?page=${input.page}`
157
          }`,
158
        }),
159
160
      createTopic: (input) =>
161
        request("create a forum topic", {
162
          ...input,
163
          method: "POST",
164
          path: "/api/v3/forum/topics",
165
          body: { forum: input.board, title: input.title, body_text: input.bodyText },
166
        }),
167
168
      reply: (input) =>
169
        request("reply to a forum topic", {
170
          ...input,
171
          method: "POST",
172
          path: `/api/v3/forum/topics/${encodeURIComponent(input.topicId)}/posts`,
173
          body: { body_text: input.bodyText },
174
        }),
175
176
      claim: (input) =>
177
        request("claim a legacy forum identity", {
178
          ...input,
179
          method: "POST",
180
          path: "/api/v3/forum/claims",
181
          body: { actor_ref: input.actorRef },
182
        }),
183
184
      claims: (input) =>
185
        request("list identity claims", { ...input, method: "GET", path: "/api/v3/forum/claims" }),
186
    };
187
  }),
188
);
packages/openagents-cli/src/runtime.ts modified +3

@@ -11,6 +11,7 @@ import { credentialStoreOsLayer } from "./credential-store.js";

11 11
import { pendingDeviceAuthorizationStoreLayer } from "./device-authorization-store.js";
12 12
import { deviceClientLayer } from "./device-client.js";
13 13
import { environmentLayer } from "./environment.js";
14
import { forumClientLayer } from "./forum-client.js";
14 15
import { gitRunnerLayer } from "./git-runner.js";
15 16
import { outputLayer } from "./output.js";
16 17
import { persistedConfigurationLayer } from "./persisted-configuration.js";

@@ -24,6 +25,7 @@ const transportLayer = apiTransportNodeLayer.pipe(

24 25
);
25 26
26 27
const repositoryLayer = repositoryClientLayer.pipe(Layer.provide(transportLayer));
28
const forumLayer = forumClientLayer.pipe(Layer.provide(transportLayer));
27 29
const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));
28 30
const credentialsLayer = credentialStoreOsLayer.pipe(Layer.provide(NodeServices.layer));
29 31
const pendingAuthorizationLayer = pendingDeviceAuthorizationStoreLayer.pipe(

@@ -53,6 +55,7 @@ export const runtimeLayer = Layer.mergeAll(

53 55
  credentialsLayer,
54 56
  pendingAuthorizationLayer,
55 57
  repositoryLayer,
58
  forumLayer,
56 59
  deviceLayer,
57 60
  browserLayer,
58 61
  computerConfiguration,

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