Public: anyone can read this; the crew can change it.
The here-manifest knows /explore and /search: it names their query API and MCP tool #4118
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4107-the-here-manifest-knows-explore-and-sear → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +78
−4
README.md +5
| @@ -2063,3 +2063,8 @@ web returns to `/` with that line. | ||
| 2063 | 2063 | `forge_egress_connections`, `forge_egress_bytes_in` and `forge_egress_bytes_out` |
| 2064 | 2064 | per `repo` and `destination` as Prometheus text, so a leaked upstream socket |
| 2065 | 2065 | shows as a gauge that never falls (#3719). |
| 2066 | ||
| 2067 | The here-manifest (`GET /api/v1/here?path=…`) reads the same for guests and | |
| 2068 | members. On `/explore` and `/search` it names the page's own read, `?q=` carried | |
| 2069 | over and encoded: `GET /api/v1/explore?q=…` (MCP tool `explore`) or | |
| 2070 | `GET /api/v1/search?q=…` (MCP tool `search`), listed once. |
src/actions/here.ts +36 −3
| @@ -49,7 +49,7 @@ export interface Action { | ||
| 49 | 49 | |
| 50 | 50 | export interface Manifest { |
| 51 | 51 | readonly here: |
| 52 | | { readonly kind: "forge" } | |
| 52 | | { readonly kind: "forge" | "explore" | "search" } | |
| 53 | 53 | | { |
| 54 | 54 | readonly kind: "repository"; |
| 55 | 55 | readonly name: string; |
| @@ -106,6 +106,10 @@ export function manifestFor( | ||
| 106 | 106 | return s; |
| 107 | 107 | } |
| 108 | 108 | }); |
| 109 | const page = | |
| 110 | name === undefined && (owner === "explore" || owner === "search") | |
| 111 | ? owner | |
| 112 | : undefined; | |
| 109 | 113 | // Two segments under a name the forge keeps for itself are its own page, |
| 110 | 114 | // never a repository (`core/reserved.ts`). |
| 111 | 115 | // `/:owner/-/…` is an account's or an org's own page (its teams), never |
| @@ -117,9 +121,15 @@ export function manifestFor( | ||
| 117 | 121 | SYSTEM.has(owner.toLowerCase()) |
| 118 | 122 | ) |
| 119 | 123 | return ok({ |
| 120 | here: { kind: "forge" }, | |
| 124 | here: { kind: page ?? "forge" }, | |
| 121 | 125 | you: youFor(viewer, scope, undefined, app), |
| 122 | actions: forgeActions(viewer), | |
| 126 | actions: | |
| 127 | page === undefined | |
| 128 | ? forgeActions(viewer) | |
| 129 | : [ | |
| 130 | pageRead(page, path), | |
| 131 | ...forgeActions(viewer).filter((a) => a.mcp !== page), | |
| 132 | ], | |
| 123 | 133 | ...common, |
| 124 | 134 | }); |
| 125 | 135 | const repo = getRepo(app.db, owner, name.replace(/\.git$/, "")); |
| @@ -203,8 +213,31 @@ function repoHere( | ||
| 203 | 213 | }; |
| 204 | 214 | } |
| 205 | 215 | |
| 216 | /** The read an explore or search page pages through, its `?q=` carried over. */ | |
| 217 | function pageRead(page: "explore" | "search", path: string): Action { | |
| 218 | const q = new URL(path, "http://forge.invalid").searchParams.get("q") ?? ""; | |
| 219 | const query = | |
| 220 | q === "" ? "" : `?q=${encodeURIComponent(q).replace(/%3A/gi, ":")}`; | |
| 221 | const http = `GET /api/v1/${page}${query}`; | |
| 222 | return { | |
| 223 | do: `${page === "explore" ? "browse the forge" : "search the forge"}${q === "" ? "" : `: ${q.slice(0, 200)}`}`, | |
| 224 | http, | |
| 225 | mcp: page, | |
| 226 | }; | |
| 227 | } | |
| 228 | ||
| 206 | 229 | function forgeActions(viewer: User | undefined): Action[] { |
| 207 | 230 | return [ |
| 231 | { | |
| 232 | do: "explore apps and repositories", | |
| 233 | http: "GET /api/v1/explore?q=", | |
| 234 | mcp: "explore", | |
| 235 | }, | |
| 236 | { | |
| 237 | do: "search issues, pulls, repositories and code", | |
| 238 | http: "GET /api/v1/search?q=", | |
| 239 | mcp: "search", | |
| 240 | }, | |
| 208 | 241 | { do: "list public repositories", http: "GET /api/v1/repos" }, |
| 209 | 242 | { |
| 210 | 243 | do: "report a problem", |
src/contract/openapi.ts +2
| @@ -49,6 +49,8 @@ export const ROUTES: readonly Route[] = [ | ||
| 49 | 49 | body: APP_BODY, |
| 50 | 50 | refusals: APP_REFUSALS, |
| 51 | 51 | }, |
| 52 | { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } }, | |
| 53 | { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } }, | |
| 52 | 54 | { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false }, |
| 53 | 55 | { |
| 54 | 56 | method: "get", |
test/here-manifest.test.ts +34
| @@ -215,6 +215,32 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 215 | 215 | expect(JSON.stringify(body)).not.toContain("vault"); |
| 216 | 216 | }); |
| 217 | 217 | |
| 218 | test("/explore and /search name their query API and MCP tool, the query carried over", async () => { | |
| 219 | for (const who of [undefined, "carol"]) { | |
| 220 | const e = (await manifest("/explore?q=kind:app", who)).body; | |
| 221 | expect(e.here).toEqual({ kind: "explore" }); | |
| 222 | expect(e.actions[0].http).toBe("GET /api/v1/explore?q=kind:app"); | |
| 223 | expect(e.actions[0].mcp).toBe("explore"); | |
| 224 | const s = (await manifest("/search", who)).body; | |
| 225 | expect(s.here).toEqual({ kind: "search" }); | |
| 226 | expect(s.actions[0].http).toBe("GET /api/v1/search"); | |
| 227 | expect(s.actions[0].mcp).toBe("search"); | |
| 228 | const f = (await manifest("/", who)).body; | |
| 229 | expect(f.here.kind).toBe("forge"); | |
| 230 | expect(f.actions.map((a: any) => a.mcp)).toEqual( | |
| 231 | expect.arrayContaining(["explore", "search"]), | |
| 232 | ); | |
| 233 | for (const a of f.actions) | |
| 234 | if (a.mcp === "explore" || a.mcp === "search") | |
| 235 | expect(a.account).toBeUndefined(); | |
| 236 | } | |
| 237 | const q = (await manifest("/search?q=a%20b%26c")).body; | |
| 238 | expect(q.actions[0].http).toBe("GET /api/v1/search?q=a%20b%26c"); | |
| 239 | const other = (await manifest("/elsewhere")).body; | |
| 240 | expect(other.here).toEqual({ kind: "forge" }); | |
| 241 | expect(other.actions[0].do).toBe("explore apps and repositories"); | |
| 242 | }); | |
| 243 | ||
| 218 | 244 | test("it is small", async () => { |
| 219 | 245 | for (const who of [undefined, "carol", "wes", "alice", "bot"]) { |
| 220 | 246 | const res = await here("/acme/game", who); |
| @@ -322,4 +348,12 @@ describe("the manifest: what you can do here, as who you are", () => { | ||
| 322 | 348 | const ownBody = (await manifest(`/acme/game/pulls/${own.value.number}`)).body; |
| 323 | 349 | expect(ownBody.here.pull).toEqual({ number: own.value.number, runs: true }); |
| 324 | 350 | }); |
| 351 | ||
| 352 | test("on /explore the page read is not repeated by the generic one", async () => { | |
| 353 | const { body } = await manifest("/explore?q=kind:app"); | |
| 354 | const reads = body.actions.filter((a: any) => a.mcp === "explore"); | |
| 355 | expect(reads.length).toBe(1); | |
| 356 | expect(reads[0].http).toBe("GET /api/v1/explore?q=kind:app"); | |
| 357 | expect(body.actions.filter((a: any) => a.mcp === "search").length).toBe(1); | |
| 358 | }); | |
| 325 | 359 | }); |
test/openapi.test.ts +1 −1
| @@ -7,7 +7,7 @@ test("every http route the manifest names is described", () => { | ||
| 7 | 7 | const src = readFileSync("src/actions/here.ts", "utf8"); |
| 8 | 8 | const named = [...src.matchAll(/http: [`"]([^`"]+)[`"]/g)].flatMap((m) => |
| 9 | 9 | m[1]!.split(", then ").map((one) => { |
| 10 | const [method, path] = one.replace(" {headRepo}", "").split(" "); | |
| 10 | const [method, path] = one.replace(" {headRepo}", "").replace(/\?.*/, "").split(" "); | |
| 11 | 11 | return [method!.toLowerCase(), path!.replaceAll("${r}", "/api/v1/repos/{owner}/{name}")] as const; |
| 12 | 12 | }), |
| 13 | 13 | ); |
new above
offline
On the page itself this duplicates pageRead; skip the generic entry when the page matches.