Public: anyone can read this; the crew can change it.
`forge tell "words"` tells the crew from your shell, about the repository of the folder you are in #7859
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7352-forge-tell-words-tells-the-crew-from-you → main 9 commits fork 3 threads · 3 unresolved
17 files
changed +361
−33
docs/CLI.md +17
| @@ -312,6 +312,23 @@ look alice/shop Pull #3 is green — look and merge https://forge.example. | ||
| 312 | 312 | parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9 |
| 313 | 313 | |
| 314 | 314 | |
| 315 | ## forge tell | |
| 316 | ||
| 317 | Tell the crew something about a repository; it becomes an issue. The | |
| 318 | repository is that of this folder's `origin` when it points at the workbench, | |
| 319 | or `--repo`. Words are 1 to 2000 characters; start them with a dash after `--`. | |
| 320 | ||
| 321 | Flags: | |
| 322 | ||
| 323 | - `--repo owner/name` — the repository, instead of this folder's origin. | |
| 324 | - `--variants 1-4` — how many attempts the crew makes. | |
| 325 | - `--from /path` — the workbench page you were looking at. | |
| 326 | ||
| 327 | console | |
| 328 | $ forge tell "make the header smaller" | |
| 329 | told alice/shop: issue #12 https://forge.example.com/alice/shop/issues/12 | |
| 330 | ||
| 331 | ||
| 315 | 332 | ## forge new |
| 316 | 333 | |
| 317 | 334 | Makes an app from the shell, as `POST /api/v1/apps` does, then clones its |
docs/ON-YOUR-MACHINE.md +9
| @@ -36,11 +36,20 @@ forge asks --repo owner/name # only that repository's; --json for the API' | ||
| 36 | 36 | forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N] |
| 37 | 37 | forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY) |
| 38 | 38 | forge asks --undo <id> # take back a decision by the receipt id it printed |
| 39 | forge tell "make the header smaller" # tell the crew, about this folder's repo (--repo owner/name overrides) | |
| 39 | 40 | forge map # the plan of this clone: working, ready, waiting |
| 40 | 41 | forge search retry budget # code, issues, pulls and repos you can read; exits 6 on "not now" |
| 41 | 42 | forge search x --kind pulls --limit 5 # one kind, five hits; --json marks truncated.issues when more matched |
| 42 | 43 | |
| 43 | 44 | |
| 45 | `forge tell` makes an issue of your words (1 to 2000 characters) and prints its | |
| 46 | address. `--variants 1-4` asks for that many attempts and `--from /path` names | |
| 47 | the workbench page you were looking at, as the API takes them. Words that start | |
| 48 | with a dash go after `--`: `forge tell -- "--fast"`. | |
| 49 | ||
| 50 | The MCP `tell` tool takes `from` but not `variants`. The terminal (tty) takes | |
| 51 | neither. | |
| 52 | ||
| 44 | 53 | `forge ship <number>` reads the repository from `--repo owner/name` or from |
| 45 | 54 | the folder's `origin` when it points at your workbench, shows the pull's |
| 46 | 55 | title, check and whether it merges, and asks `Ship #12? [y/N]`. `--yes` skips |
src/actions/tell.ts +1 −23
| @@ -17,7 +17,7 @@ import { routeIssue, type Routed } from "../crew/route.ts"; | ||
| 17 | 17 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 18 | 18 | import { requireParticipant } from "./access.ts"; |
| 19 | 19 | import { assignIssue, openIssue, type MirrorError } from "./issues.ts"; |
| 20 | import { scrubQuery } from "./tell_context.ts"; | |
| 20 | import { parseFrom } from "./tell_from.ts"; | |
outdated
Export-from plus import of the same module is noise. Import once, and point other callers at tell_from.ts. |
||
| 21 | 21 | import { requireVerified } from "./verify.ts"; |
| 22 | 22 | import { VERIFY_FIRST } from "./verify_words.ts"; |
| 23 | 23 | |
| @@ -45,28 +45,6 @@ export function parseVariants(raw: unknown): number | undefined { | ||
| 45 | 45 | return raw >= 1 && raw <= VARIANTS_MAX ? raw : undefined; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | /** The longest `from` a tell may carry. */ | |
| 49 | export const FROM_MAX = 512; | |
| 50 | ||
| 51 | /** | |
| 52 | * `from` as the doors take it: absent is none; otherwise a same-forge path | |
| 53 | * (leading single `/`, no scheme, no backslash, no control or space) of at | |
| 54 | * most 512 characters. Undefined for no. A query parameter that carries a | |
| 55 | * secret (`scrubQuery`) is dropped; the rest of the path is kept as given. | |
| 56 | */ | |
| 57 | export function parseFrom(raw: unknown): string | null | undefined { | |
| 58 | if (raw === undefined || raw === null) return null; | |
| 59 | if (typeof raw !== "string" || raw.length === 0 || raw.length > FROM_MAX) | |
| 60 | return undefined; | |
| 61 | if (!raw.startsWith("/") || raw.startsWith("//")) return undefined; | |
| 62 | if (/[\\\s\x00-\x1f\x7f<>()\[\]`"'*]/.test(raw)) return undefined; | |
| 63 | const q = raw.indexOf("?"); | |
| 64 | const h = raw.indexOf("#"); | |
| 65 | if (q === -1 || (h !== -1 && h < q)) return raw; | |
| 66 | const end = h === -1 ? raw.length : h; | |
| 67 | return `${raw.slice(0, q)}${scrubQuery(raw.slice(q, end))}${raw.slice(end)}`; | |
| 68 | } | |
| 69 | ||
| 70 | 48 | /** |
| 71 | 49 | * An app's own address as a place a tell was said: scheme and host (and |
| 72 | 50 | * port) only. Only the app terminal's door builds one and says `fromApp`; every other |
src/actions/tell_from.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * Where a tell was said: the one rule for `from`, pure, so the CLI refuses | |
| 3 | * what the server would before it spends a round trip. | |
| 4 | */ | |
| 5 | ||
| 6 | import { scrubQuery } from "./tell_context.ts"; | |
| 7 | ||
| 8 | /** The most characters a tell's words may have; every door refuses more. */ | |
| 9 | export const TELL_TEXT_MAX = 2000; | |
| 10 | ||
| 11 | /** The longest `from` a tell may carry. */ | |
| 12 | export const FROM_MAX = 512; | |
| 13 | ||
| 14 | /** | |
| 15 | * `from` as the doors take it: absent is none; otherwise a same-forge path | |
| 16 | * (leading single `/`, no scheme, no backslash, no control or space) of at | |
| 17 | * most 512 characters. Undefined for no. A query parameter that carries a | |
| 18 | * secret (`scrubQuery`) is dropped; the rest of the path is kept as given. | |
| 19 | */ | |
| 20 | export function parseFrom(raw: unknown): string | null | undefined { | |
| 21 | if (raw === undefined || raw === null) return null; | |
| 22 | if (typeof raw !== "string" || raw.length === 0 || raw.length > FROM_MAX) | |
| 23 | return undefined; | |
| 24 | if (!raw.startsWith("/") || raw.startsWith("//")) return undefined; | |
| 25 | if (/[\\\s\x00-\x1f\x7f<>()\[\]`"'*]/.test(raw)) return undefined; | |
| 26 | const q = raw.indexOf("?"); | |
| 27 | const h = raw.indexOf("#"); | |
| 28 | if (q === -1 || (h !== -1 && h < q)) return raw; | |
| 29 | const end = h === -1 ? raw.length : h; | |
| 30 | return `${raw.slice(0, q)}${scrubQuery(raw.slice(q, end))}${raw.slice(end)}`; | |
| 31 | } |
src/cli/api.ts +30
| @@ -433,6 +433,36 @@ export class ForgeApi { | ||
| 433 | 433 | return ok({ raw: out.value, asks }); |
| 434 | 434 | } |
| 435 | 435 | |
| 436 | /** | |
| 437 | * Tell the crew something about `owner/name` (`POST /api/v1/feed/tells`). | |
| 438 | * Answers the issue it became, with its address on the workbench. | |
| 439 | */ | |
| 440 | async tell( | |
| 441 | owner: string, | |
| 442 | name: string, | |
| 443 | text: string, | |
| 444 | more: { variants?: number | undefined; from?: string | undefined } = {}, | |
| 445 | ): Promise<ApiResult<{ issue: number; url: string }>> { | |
| 446 | const out = await this.call<{ issue?: unknown }>("/api/v1/feed/tells", { | |
| 447 | method: "POST", | |
| 448 | body: JSON.stringify({ | |
| 449 | app: `${owner}/${name}`, | |
| 450 | text, | |
| 451 | ...(more.variants === undefined ? {} : { variants: more.variants }), | |
| 452 | ...(more.from === undefined ? {} : { from: more.from }), | |
| 453 | }), | |
| 454 | }); | |
| 455 | if (!out.ok) return out; | |
| 456 | const issue = out.value.issue; | |
| 457 | if (typeof issue !== "number") | |
| 458 | return err({ | |
| 459 | code: "bad_response", | |
| 460 | message: "the forge did not say which issue the tell became", | |
| 461 | status: 0, | |
| 462 | }); | |
| 463 | return ok({ issue, url: `${this.webUrl(owner, name)}/issues/${issue}` }); | |
| 464 | } | |
| 465 | ||
| 436 | 466 | /** |
| 437 | 467 | * `GET /api/v1/search`: code, issues, pulls and repositories the token can |
| 438 | 468 | * read. The answer is handed back as the forge gave it — the CLI prints |
src/cli/commands/index.ts +2
| @@ -17,6 +17,7 @@ import { command as runs } from "./runs.ts"; | ||
| 17 | 17 | import { command as asks } from "./asks.ts"; |
| 18 | 18 | import { command as map } from "./map.ts"; |
| 19 | 19 | import { command as search } from "./search.ts"; |
| 20 | import { command as tell } from "./tell.ts"; | |
| 20 | 21 | import { command as newApp } from "./new.ts"; |
| 21 | 22 | import { command as ship } from "./ship.ts"; |
| 22 | 23 | import type { Command } from "./types.ts"; |
| @@ -37,6 +38,7 @@ export const commands: readonly Command[] = [ | ||
| 37 | 38 | asks, |
| 38 | 39 | map, |
| 39 | 40 | search, |
| 41 | tell, | |
| 40 | 42 | newApp, |
| 41 | 43 | ship, |
| 42 | 44 | ]; |
src/cli/commands/tell.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | import { parseFrom, TELL_TEXT_MAX } from "../../actions/tell_from.ts"; | |
| 2 | import { ForgeApi } from "../api.ts"; | |
| 3 | import type { Args } from "../args.ts"; | |
| 4 | import { requireConfig } from "../context.ts"; | |
| 5 | import { EXIT } from "../exit.ts"; | |
| 6 | import { resolveRepo } from "../here.ts"; | |
| 7 | import { inert, type Out } from "../out.ts"; | |
| 8 | import type { Command } from "./types.ts"; | |
| 9 | ||
| 10 | /** | |
| 11 | * `forge tell "words"`: tell the crew something about a repository; it | |
| 12 | * becomes an issue. Every rule here is the server's, checked early. | |
| 13 | */ | |
| 14 | async function tell(out: Out, args: Args): Promise<number> { | |
| 15 | const text = args.positional.join(" ").trim(); | |
| 16 | if (text === "") { | |
| 17 | out.err( | |
| 18 | "forge tell: say something — " + | |
| 19 | 'forge tell "words" [--repo owner/name] [--variants 1-4] [--from /path]', | |
| 20 | ); | |
| 21 | return EXIT.usage; | |
| 22 | } | |
| 23 | if (text.length > TELL_TEXT_MAX) { | |
| 24 | out.err(`forge tell: the words are 1 to ${TELL_TEXT_MAX} characters`); | |
outdated
The literal 2000 repeats the server rule. Share the constant so the CLI cannot drift. |
||
| 25 | return EXIT.usage; | |
| 26 | } | |
| 27 | const only = args.value("repo"); | |
| 28 | const rawVariants = args.value("variants"); | |
| 29 | const variants = rawVariants === undefined ? undefined : Number(rawVariants); | |
| 30 | if ( | |
| 31 | variants !== undefined && | |
| 32 | !(Number.isInteger(variants) && variants >= 1 && variants <= 4) | |
| 33 | ) { | |
| 34 | out.err("forge tell: --variants is a whole number from 1 to 4"); | |
| 35 | return EXIT.usage; | |
| 36 | } | |
| 37 | const from = args.value("from"); | |
| 38 | if (from !== undefined && parseFrom(from) === undefined) { | |
| 39 | out.err( | |
| 40 | "forge tell: --from is a path on the workbench, like /alice/notes/pulls/3", | |
| 41 | ); | |
| 42 | return EXIT.usage; | |
| 43 | } | |
| 44 | const loaded = await requireConfig(out); | |
| 45 | if (typeof loaded === "number") return loaded; | |
| 46 | const ref = await resolveRepo(only, loaded.url); | |
| 47 | if (typeof ref === "string") { | |
| 48 | out.err(`forge tell: ${ref}`); | |
| 49 | return EXIT.usage; | |
| 50 | } | |
| 51 | const told = await new ForgeApi(loaded.url, loaded.token).tell( | |
| 52 | ref.owner, | |
| 53 | ref.name, | |
| 54 | text, | |
| 55 | { variants, from }, | |
| 56 | ); | |
| 57 | if (!told.ok) { | |
| 58 | out.err(`forge tell: ${inert(told.error.message)}`); | |
| 59 | return told.error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 60 | } | |
| 61 | out.line( | |
| 62 | `told ${ref.owner}/${ref.name}: issue #${told.value.issue} ${told.value.url}`, | |
| 63 | ); | |
| 64 | return EXIT.ok; | |
| 65 | } | |
| 66 | ||
| 67 | export const command: Command = { | |
| 68 | name: "tell", | |
| 69 | usage: { | |
| 70 | synopsis: "forge tell <words…>", | |
| 71 | flags: "[--repo owner/name] [--variants 1-4] [--from /path] [--]", | |
| 72 | }, | |
| 73 | run: tell, | |
| 74 | }; |
src/cli/main.ts +2 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * forge sync clone what is missing, fetch the rest, report |
| 8 | 8 | * forge status the same report without touching the network |
| 9 | 9 | * forge asks what waits on you, most important first |
| 10 | * forge tell "words" tell the crew about this folder's repository | |
| 10 | 11 | * forge search <words> code, issues, pulls and repos you can read |
| 11 | 12 | * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N |
| 12 | 13 | * forge asks --undo <id> take back what that decided |
| @@ -35,7 +36,7 @@ export { EXIT }; | ||
| 35 | 36 | /** Every command by name, built once from `commands/index.ts`. */ |
| 36 | 37 | const TABLE = new Map(commands.map((c) => [c.name, c])); |
| 37 | 38 | |
| 38 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner"]; | |
| 39 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner", "variants", "from"]; | |
| 39 | 40 | |
| 40 | 41 | export async function main( |
| 41 | 42 | argv: readonly string[], |
src/http/feed.ts +3 −3
| @@ -5,12 +5,12 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | parseVariants, |
| 8 | parseFrom, | |
| 9 | 8 | FROM_WORDS, |
| 10 | 9 | tell, |
| 11 | 10 | UNVERIFIED_WORDS, |
| 12 | 11 | VARIANTS_MAX, |
| 13 | 12 | } from "../actions/tell.ts"; |
| 13 | import { parseFrom, TELL_TEXT_MAX } from "../actions/tell_from.ts"; | |
| 14 | 14 | import { DIRECTION_NOTE } from "../crew/direct.ts"; |
| 15 | 15 | import { readInteraction } from "../feed/interact.ts"; |
| 16 | 16 | import { |
| @@ -253,8 +253,8 @@ export function feedRoutes(router: Router<Handler>): void { | ||
| 253 | 253 | const viewer = ctx.auth.user!; |
| 254 | 254 | const input = await body(ctx.request); |
| 255 | 255 | const text = str(input.text).trim(); |
| 256 | if (text === "" || text.length > 2000) | |
| 257 | return apiError("invalid_text", 422, "text is 1 to 2000 characters"); | |
| 256 | if (text === "" || text.length > TELL_TEXT_MAX) | |
| 257 | return apiError("invalid_text", 422, `text is 1 to ${TELL_TEXT_MAX} characters`); | |
| 258 | 258 | const variants = parseVariants(input.variants); |
| 259 | 259 | if (variants === undefined) |
| 260 | 260 | return apiError( |
src/http/mcp/tools/tell.ts +2 −1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { contextNote, parseContext } from "../../../actions/tell_context.ts"; |
| 2 | 2 | import { keepThought } from "../../../actions/thought.ts"; |
| 3 | import { FROM_WORDS, parseFrom, tell, toldWords, UNVERIFIED_WORDS } from "../../../actions/tell.ts"; | |
| 3 | import { parseFrom } from "../../../actions/tell_from.ts"; | |
| 4 | import { FROM_WORDS, tell, toldWords, UNVERIFIED_WORDS } from "../../../actions/tell.ts"; | |
| 4 | 5 | import { DIRECTION_NOTE } from "../../../crew/direct.ts"; |
| 5 | 6 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 6 | 7 |
src/web/scope.ts +1 −1
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | * looking at a clone or at somebody else's server. |
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | import { parseFrom } from "../actions/tell.ts"; | |
| 25 | import { parseFrom } from "../actions/tell_from.ts"; | |
| 26 | 26 | import { getAppByRepo } from "../core/apps.ts"; |
| 27 | 27 | import { mayDirectCrew } from "../crew/direct.ts"; |
| 28 | 28 | import { isPinned } from "../core/pins.ts"; |
src/web/studio/actions.ts +2 −1
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | import { mayView } from "../../core/sealed.ts"; |
| 12 | 12 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| 13 | 13 | import { addComment } from "../../actions/issues.ts"; |
| 14 | import { parseFrom, SENT_WORDS, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts"; | |
| 14 | import { parseFrom } from "../../actions/tell_from.ts"; | |
| 15 | import { SENT_WORDS, tell, toldWords, TOO_LONG_WORDS } from "../../actions/tell.ts"; | |
| 15 | 16 | import { contextNote, parseContext } from "../../actions/tell_context.ts"; |
| 16 | 17 | import { NOT_TOLD_FROM_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; |
| 17 | 18 | import { keepThought } from "../../actions/thought.ts"; |
src/web/studio/tell_here.ts +1 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { parseContext } from "../../actions/tell_context.ts"; |
| 10 | import { parseFrom } from "../../actions/tell.ts"; | |
| 10 | import { parseFrom } from "../../actions/tell_from.ts"; | |
| 11 | 11 | import { mayDirectCrew } from "../../crew/direct.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; |
test/cli-tell.test.ts added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | /** `forge tell` against a fake `POST /api/v1/feed/tells`, run as a shell runs it. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { EXIT } from "../src/cli/main.ts"; | |
| 8 | ||
| 9 | const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname; | |
| 10 | ||
| 11 | let server: ReturnType<typeof Bun.serve>; | |
| 12 | let base = ""; | |
| 13 | let dir = ""; | |
| 14 | let reply: () => Response = () => Response.json({ issue: 12 }, { status: 201 }); | |
| 15 | const seen: { auth: string | null; body: unknown }[] = []; | |
| 16 | ||
| 17 | beforeAll(async () => { | |
| 18 | dir = await mkdtemp(join(tmpdir(), "forge-tell-")); | |
| 19 | server = Bun.serve({ | |
| 20 | port: 0, | |
| 21 | async fetch(req) { | |
| 22 | const url = new URL(req.url); | |
| 23 | if (url.pathname !== "/api/v1/feed/tells" || req.method !== "POST") | |
| 24 | return Response.json({ error: "not_found", message: "no" }, { status: 404 }); | |
| 25 | seen.push({ auth: req.headers.get("authorization"), body: await req.json() }); | |
| 26 | return reply(); | |
| 27 | }, | |
| 28 | }); | |
| 29 | base = `http://127.0.0.1:${server.port}`; | |
| 30 | for (const [name, remote] of [ | |
| 31 | ["clone", `${base}/alice/shop.git`], | |
| 32 | ["other", "https://example.com/a/b.git"], | |
| 33 | ] as const) { | |
| 34 | Bun.spawnSync(["git", "init", "-q", join(dir, name)]); | |
| 35 | Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: join(dir, name) }); | |
| 36 | } | |
| 37 | }); | |
| 38 | ||
| 39 | afterAll(async () => { | |
| 40 | server.stop(true); | |
| 41 | await rm(dir, { recursive: true, force: true }); | |
| 42 | }); | |
| 43 | ||
| 44 | async function forge(args: string[], cwd = join(dir, "clone")) { | |
| 45 | const proc = Bun.spawn(["bun", CLI, ...args], { | |
| 46 | cwd, | |
| 47 | env: { | |
| 48 | PATH: process.env["PATH"] ?? "", | |
| 49 | HOME: process.env["HOME"] ?? "", | |
| 50 | FORGE_URL: base, | |
| 51 | FORGE_TOKEN: "tok", | |
| 52 | FORGE_CONFIG_DIR: dir, | |
| 53 | FORGE_KEYCHAIN: "0", | |
| 54 | NO_COLOR: "1", | |
| 55 | }, | |
| 56 | stdout: "pipe", | |
| 57 | stderr: "pipe", | |
| 58 | }); | |
| 59 | const [out, err, code] = await Promise.all([ | |
| 60 | new Response(proc.stdout).text(), | |
| 61 | new Response(proc.stderr).text(), | |
| 62 | proc.exited, | |
| 63 | ]); | |
| 64 | return { code, out, err }; | |
| 65 | } | |
| 66 | ||
| 67 | test("posts the words about the folder's repository and prints the issue address", async () => { | |
| 68 | const run = await forge(["tell", "make", "the", "header", "smaller"]); | |
| 69 | expect(run.code).toBe(EXIT.ok); | |
| 70 | expect(seen.at(-1)).toEqual({ | |
| 71 | auth: "Bearer tok", | |
| 72 | body: { app: "alice/shop", text: "make the header smaller" }, | |
| 73 | }); | |
| 74 | expect(run.out).toContain(`issue #12 ${base}/alice/shop/issues/12`); | |
| 75 | }); | |
| 76 | ||
| 77 | test("`--` ends the options, so words may start with a dash", async () => { | |
| 78 | const run = await forge(["tell", "--", "--fast", "please"]); | |
| 79 | expect(run.code).toBe(EXIT.ok); | |
| 80 | expect((seen.at(-1)!.body as { text: string }).text).toBe("--fast please"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("--variants and --from go to the API as given", async () => { | |
| 84 | const run = await forge([ | |
| 85 | "tell", "try", "it", "--variants", "3", "--from", "/alice/shop/pulls/3", | |
| 86 | ]); | |
| 87 | expect(run.code).toBe(EXIT.ok); | |
| 88 | expect(seen.at(-1)!.body).toEqual({ | |
| 89 | app: "alice/shop", | |
| 90 | text: "try it", | |
| 91 | variants: 3, | |
| 92 | from: "/alice/shop/pulls/3", | |
| 93 | }); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a bad --variants or --from exits 2 without a request", async () => { | |
| 97 | const before = seen.length; | |
| 98 | const bads = [ | |
| 99 | ["--variants", "9"], | |
| 100 | ["--variants", "x"], | |
| 101 | ["--from", "https://evil.example/"], | |
| 102 | ["--from", "/a b"], | |
| 103 | ["--from", "/a\"b"], | |
| 104 | ["--from", "//evil"], | |
| 105 | ]; | |
| 106 | for (const bad of bads) { | |
| 107 | expect((await forge(["tell", "hi", ...bad])).code).toBe(EXIT.usage); | |
| 108 | } | |
| 109 | expect(seen.length).toBe(before); | |
| 110 | }); | |
| 111 | ||
| 112 | test("--repo overrides the folder, even one that is not a clone", async () => { | |
| 113 | const run = await forge(["tell", "hi", "--repo", "bob/lab"], dir); | |
| 114 | expect(run.code).toBe(EXIT.ok); | |
| 115 | expect((seen.at(-1)!.body as { app: string }).app).toBe("bob/lab"); | |
| 116 | }); | |
| 117 | ||
| 118 | test("empty words print usage and exit 2 without a request", async () => { | |
| 119 | const before = seen.length; | |
| 120 | const run = await forge(["tell", " "]); | |
| 121 | expect(run.code).toBe(EXIT.usage); | |
| 122 | expect(run.err).toContain("forge tell"); | |
| 123 | expect(seen.length).toBe(before); | |
| 124 | }); | |
| 125 | ||
| 126 | test("an origin elsewhere asks for --repo and exits 2", async () => { | |
| 127 | const run = await forge(["tell", "hi"], join(dir, "other")); | |
| 128 | expect(run.code).toBe(EXIT.usage); | |
| 129 | expect(run.err).toContain("--repo"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a refusal prints the API's words and exits 1; a 401 exits 3", async () => { | |
| 133 | reply = () => | |
| 134 | Response.json({ error: "not_found", message: "no such repository" }, { status: 404 }); | |
| 135 | const gone = await forge(["tell", "hi"]); | |
| 136 | expect(gone.code).toBe(EXIT.failed); | |
| 137 | expect(gone.err).toContain("no such repository"); | |
| 138 | reply = () => Response.json({ error: "unauthorized", message: "bad token" }, { status: 401 }); | |
| 139 | expect((await forge(["tell", "hi"])).code).toBe(EXIT.notConfigured); | |
| 140 | reply = () => Response.json({ issue: 12 }, { status: 201 }); | |
| 141 | }); | |
| 142 | ||
| 143 | test("not logged in exits 3", async () => { | |
| 144 | const proc = Bun.spawn(["bun", CLI, "tell", "hi"], { | |
| 145 | cwd: join(dir, "clone"), | |
| 146 | env: { PATH: process.env["PATH"] ?? "", HOME: dir, FORGE_CONFIG_DIR: join(dir, "empty"), FORGE_KEYCHAIN: "0" }, | |
| 147 | stdout: "pipe", | |
| 148 | stderr: "pipe", | |
| 149 | }); | |
| 150 | expect(await proc.exited).toBe(EXIT.notConfigured); | |
| 151 | }); | |
| 152 | ||
| 153 | test("help lists the command", async () => { | |
| 154 | expect((await forge(["help"])).out).toContain("forge tell"); | |
| 155 | }); |
test/tell-context-scrub.test.ts +2 −1
| @@ -11,7 +11,8 @@ import { tmpdir } from "node:os"; | ||
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 14 | import { parseFrom, tell } from "../src/actions/tell.ts"; | |
| 14 | import { parseFrom } from "../src/actions/tell_from.ts"; | |
| 15 | import { tell } from "../src/actions/tell.ts"; | |
| 15 | 16 | import { contextNote, parseContext } from "../src/actions/tell_context.ts"; |
| 16 | 17 | import { listIssues } from "../src/core/issues.ts"; |
| 17 | 18 | import type { Repo } from "../src/core/repos.ts"; |
test/tell-from-parse.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** `parseFrom` and the words limit, in their own module (src/actions/tell_from.ts). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { FROM_MAX, parseFrom, TELL_TEXT_MAX } from "../src/actions/tell_from.ts"; | |
| 4 | ||
| 5 | test("absent from is none; a same-forge path is kept", () => { | |
| 6 | expect(parseFrom(undefined)).toBeNull(); | |
| 7 | expect(parseFrom(null)).toBeNull(); | |
| 8 | expect(parseFrom("/alice/notes/pulls/3")).toBe("/alice/notes/pulls/3"); | |
| 9 | }); | |
| 10 | ||
| 11 | test("anything off the forge, odd or too long is refused", () => { | |
| 12 | for (const bad of ["", "alice", "//evil.test/x", "https://x/y", "/a b", "/a\\b", "/a\nb", "/a<b", 5, "/" + "x".repeat(FROM_MAX)]) | |
| 13 | expect(parseFrom(bad)).toBeUndefined(); | |
| 14 | expect(parseFrom("/" + "x".repeat(FROM_MAX - 1))).toBeDefined(); | |
| 15 | }); | |
| 16 | ||
| 17 | test("a secret in the query is scrubbed, the path and fragment are kept", () => { | |
| 18 | const out = parseFrom("/alice/notes?token=abc123secret&tab=pulls#top"); | |
| 19 | expect(out).toStartWith("/alice/notes?"); | |
| 20 | expect(out).not.toContain("abc123secret"); | |
| 21 | expect(out).toEndWith("#top"); | |
| 22 | expect(parseFrom("/alice/notes?tab=pulls")).toBe("/alice/notes?tab=pulls"); | |
| 23 | }); | |
| 24 | ||
| 25 | test("the words limit is the one the doors share", () => { | |
| 26 | expect(TELL_TEXT_MAX).toBe(2000); | |
| 27 | }); |
test/tell_from.test.ts +2 −1
| @@ -6,7 +6,8 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { setIssueState } from "../src/actions/issues.ts"; |
| 8 | 8 | import { createRepo } from "../src/actions/repos.ts"; |
| 9 | import { parseFrom, tell } from "../src/actions/tell.ts"; | |
| 9 | import { parseFrom } from "../src/actions/tell_from.ts"; | |
| 10 | import { tell } from "../src/actions/tell.ts"; | |
| 10 | 11 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 11 | 12 | import { getIssue, listIssues } from "../src/core/issues.ts"; |
| 12 | 13 | import type { Repo } from "../src/core/repos.ts"; |
new above
offline
Move this out of the asks block and shorten it. Put the MCP/tty differences in a sentence below the block.