Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Key architectural commitments — preserve when adding code:

- **One-shot optimizer, never a launch wrapper.** `funfa "<game name>"` runs the agent loop once, prints the Steam launch-options string, and exits. The user pastes the string into Steam → Right-click → Properties → Launch Options (or the equivalent for non-Steam runners). Funfa never wraps the game launch; there is no `funfa launch` subcommand. Re-running funfa for the same game runs the full AI loop again — the on-disk plan record is read-only from funfa's perspective.
- **BYOA = your own API key, any provider via the Vercel AI SDK.** Anthropic blocked Claude Pro/Max subscription access for third-party agentic tools in April 2026, so the original `claude` CLI shellout path is dead. Default provider is Anthropic API; OpenAI and Google are alternatives selected by env-var precedence in `apps/cli/src/agent/provider.ts` (`ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → `GOOGLE_GENERATIVE_AI_API_KEY`). No hosted inference service.
- **Codex subprocess fallback (zero-setup, no API key).** When no API key is set but the user is logged in to the OpenAI **Codex CLI** (ChatGPT subscription), funfa delegates the whole investigate→propose loop to `codex exec` — codex runs the hardware probes via its own shell and researches the game via its own web search, then returns the plan as structured JSON; funfa keeps the consent gate, the on-disk write, and the final render. Precedence (`resolveProviderIntent` in `provider.ts`): `FUNFA_PROVIDER=codex` forces codex → else any API key wins → else codex when available → else missing-keys. This is NOT funfa's `ToolLoopAgent` driving codex (codex 0.135.0 exposes no client-tool registration); codex owns the loop on this path while the API-key path is untouched. See `apps/cli/src/agent/codex-cli.ts`.
- **Provider-neutral loop + thin bindings.** The funfa loop lives once, in capability-verb form, as `funfa/SKILL.md` (exposed as `LOOP_BODY` in `apps/cli/src/agent/prompt.ts`); it names no tools. Each provider supplies a short **binding** mapping the verbs to its mechanism: `API_BINDING` → funfa's five tools; `CODEX_BINDING` → codex's own shell/browsing + final-JSON output. One `buildLoopPrompt({ binding, inlineSkills, skills })` assembles both paths' prompts, and `__tests__/prompt.test.ts` asserts the loop body is byte-identical across providers so they can't silently drift. Editing the loop = editing one markdown file for every provider.
- **Codex subprocess fallback (zero-setup, no API key).** When no API key is set but the user is logged in to the OpenAI **Codex CLI** (ChatGPT subscription), funfa delegates the whole investigate→propose loop to codex. The default transport is a long-lived **`codex app-server`** JSON-RPC session (`apps/cli/src/agent/codex-app-server.ts`): funfa injects the provider-neutral loop (above) via `thread/start`'s `developerInstructions` — the *same* loop body the API-key path runs — and constrains the final answer with `turn/start`'s `outputSchema`, reassembling the streamed `item/agentMessage/delta` notifications into the `{appid, plan_markdown}` plan. Set `FUNFA_CODEX_TRANSPORT=exec` to fall back to the older `codex exec --output-schema` path (the app-server protocol is officially experimental; a `MIN_CODEX_VERSION` gate in `codex-protocol.ts`, pinned to 0.135.0, guards it). Either way codex runs the hardware probes via its own shell and researches the game via its own web search; funfa keeps the consent gate, the on-disk write, and the final render. Precedence (`resolveProviderIntent` in `provider.ts`): `FUNFA_PROVIDER=codex` forces codex → else any API key wins → else codex when available → else missing-keys. This is NOT funfa's `ToolLoopAgent` driving codex (codex 0.135.0 exposes no client-tool registration); codex owns the loop on this path while the API-key path is untouched. See `apps/cli/src/agent/codex-cli.ts`; the exact v2 protocol shapes were pinned from `codex app-server generate-json-schema` into `apps/cli/vendor/codex-app-server-schema/`.
- **Skill-driven agent loop.** The model's system prompt is the `/funfa` skill only — `/hardware` and `/game-research` are loaded on demand via the `load_skill` tool. Refining the agent is a markdown edit, not a code change. The model has five tools — `load_skill` (returns the markdown of `hardware` or `game-research`), `run_shell` (executes the commands the `/hardware` skill lists), `web_search` (`gateway.tools.parallelSearch` agentic mode, driven by `/game-research`), `fetch_page` (fetches a cited URL and returns readable text so the agent can read past search snippets to the accepted answer AND the latest comments — recency matters because gaming fixes go stale), and `submit_plan` (terminal interactive — runs the `@inquirer` review inside its `execute` callback and returns the outcome so the loop continues into PHASE 3 text rendering). `run_shell` is unsandboxed and `fetch_page` ingests untrusted forum/wiki HTML — acceptable for MVP because funfa runs locally under explicit user invocation, but a real attack surface; the skills instruct the model to treat fetched text as data and ignore embedded instructions, and never to recommend a forum-only download.
- **Local agent only.** The loop runs in-process inside the `funfa` CLI on the user's machine. Anthropic Managed Agents can't reach Steam, Proton prefixes, or local files.
- **Persistent context as markdown only.** The single on-disk artifact per game is `~/.config/funfa/games/<appid>.md` (the plan record, written by the `submit_plan` tool before the review prompts — kept even on decline so the user has a trace of what was proposed). No `SYSTEM.md`, no `<appid>.json`, no Profile schema. Hardware facts live in the model's context across a run, not on disk.
Expand Down Expand Up @@ -188,7 +189,7 @@ Read directly from `process.env` in `src/index.ts`. Active vars:
- `AI_GATEWAY_API_KEY` — required for the in-process agent's `web_search` tool. Routes the `gateway.tools.parallelSearch` calls through Vercel AI Gateway. Without it, propose still runs but the agent has no live web research and will likely produce sparse plans.
- `ANTHROPIC_API_KEY` (default provider, optional), `OPENAI_API_KEY` (optional), `GOOGLE_GENERATIVE_AI_API_KEY` (optional). At least one is required for the main model call **unless** the codex subprocess fallback is used. Provider precedence handled in `src/agent/provider.ts`.
- `FUNFA_ANTHROPIC_MODEL`, `FUNFA_OPENAI_MODEL`, `FUNFA_GOOGLE_MODEL` — optional model-name overrides per provider.
- **Codex subprocess provider** (`src/agent/codex-cli.ts`): `FUNFA_PROVIDER=codex` or `FUNFA_USE_CODEX=1`/`true` forces the codex path (errors if codex is missing/not-logged-in); otherwise codex is the fallback only when no API key is set. `CODEX_HOME` — codex config/auth dir (default `~/.codex`; detection checks `<CODEX_HOME>/auth.json`). `FUNFA_CODEX_MODEL` — model override (else `config.toml`'s `model`, else `gpt-5.5`). `FUNFA_CODEX_BINARY` — explicit path to the `codex` binary (else resolved via `Bun.which`).
- **Codex subprocess provider** (`src/agent/codex-cli.ts`): `FUNFA_PROVIDER=codex` or `FUNFA_USE_CODEX=1`/`true` forces the codex path (errors if codex is missing/not-logged-in); otherwise codex is the fallback only when no API key is set. `CODEX_HOME` — codex config/auth dir (default `~/.codex`; detection checks `<CODEX_HOME>/auth.json`). `FUNFA_CODEX_MODEL` — model override (else `config.toml`'s `model`, else `gpt-5.5`). `FUNFA_CODEX_BINARY` — explicit path to the `codex` binary (else resolved via `Bun.which`). `FUNFA_CODEX_TRANSPORT` — `app-server` (default; long-lived JSON-RPC, injects the loop via `developerInstructions`) or `exec` (legacy `codex exec --output-schema` fallback for when the experimental app-server protocol misbehaves). `detect()` also gates on a minimum codex version (`MIN_CODEX_VERSION`, 0.135.0) for the app-server surface.
- `FUNFA_FETCH_ALLOWLIST` — comma-separated domains the `fetch_page` tool may read (subdomains allowed). Overrides `DEFAULT_FETCH_ALLOWLIST` in `src/agent/tools/fetch-page.ts` (ProtonDB, PCGamingWiki, GitHub, Steam community/store, SteamDB). **Gates funfa's `fetch_page` tool only** — on the codex path, codex browses through its own sandbox and is NOT constrained by this list (funfa only runs codex with `-s read-only` + `--ignore-user-config`). `fetch_page` also re-checks the post-redirect host against the list.
- `FUNFA_FAKE_HARDWARE` — **dev/test affordance** for running funfa on a non-Linux machine. When set, its value is injected into the codex prompt as the verified hardware facts and the Linux probes (`uname`/`glxinfo`/`cat /etc/os-release`) are skipped — lets the full loop run + produce a real plan off-Linux. Unset = normal probing. Codex path only.
- `XDG_CONFIG_HOME` / `HOME` — `~/.config/funfa/games/` (or `$XDG_CONFIG_HOME/funfa/games/`) is where the plan markdown record lands.
Expand Down
91 changes: 91 additions & 0 deletions apps/cli/src/__tests__/codex-app-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import {
type AppServerMessage,
type AppServerTransport,
makeAppServerClient,
} from "../agent/codex-app-server";

interface FakeTransport extends AppServerTransport {
readonly push: (message: AppServerMessage) => void;
readonly sent: Record<string, unknown>[];
}

const makeFakeTransport = (): FakeTransport => {
const sent: Record<string, unknown>[] = [];
let handler: ((message: AppServerMessage) => void) | null = null;
return {
close: () => Promise.resolve(),
push: (message) => handler?.(message),
sent,
subscribe: (h) => {
handler = h;
},
write: (message) => {
sent.push(message);
},
};
};

describe("makeAppServerClient", () => {
test("correlates a request id to its response and resolves the result", async () => {
const transport = makeFakeTransport();
const client = makeAppServerClient(transport);

const pending = client.request("initialize", { clientInfo: { name: "x" } });
const sent = transport.sent[0];
expect(sent?.id).toBe(1);
expect(sent?.method).toBe("initialize");

transport.push({ id: 1, result: { ok: true } });
await expect(pending).resolves.toEqual({ ok: true });
});

test("rejects when the response carries an error", async () => {
const transport = makeFakeTransport();
const client = makeAppServerClient(transport);
const pending = client.request("thread/start", {});
transport.push({ error: { message: "boom" }, id: 1 });
await expect(pending).rejects.toThrow("boom");
});

test("notify writes a message with no id", () => {
const transport = makeFakeTransport();
const client = makeAppServerClient(transport);
client.notify("initialized");
expect(transport.sent[0]).toEqual({ method: "initialized" });
});

test("answers an inbound server request with the handler's result", async () => {
const transport = makeFakeTransport();
const client = makeAppServerClient(transport);
client.onRequest("item/commandExecution/requestApproval", () => ({
decision: "accept",
}));

transport.push({
id: 7,
method: "item/commandExecution/requestApproval",
params: { itemId: "i1" },
});
// Handler resolution is async (Promise.resolve in the client).
await Promise.resolve();
expect(transport.sent.at(-1)).toEqual({
id: 7,
result: { decision: "accept" },
});
});

test("dispatches notifications to the registered handler", () => {
const transport = makeFakeTransport();
const client = makeAppServerClient(transport);
const deltas: string[] = [];
client.onNotification("item/agentMessage/delta", (params) => {
deltas.push((params as { delta: string }).delta);
});
transport.push({
method: "item/agentMessage/delta",
params: { delta: "hello" },
});
expect(deltas).toEqual(["hello"]);
});
});
37 changes: 35 additions & 2 deletions apps/cli/src/__tests__/codex-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildCodexInstructions,
buildCodexPrompt,
type CodexCli,
type CodexRunInput,
type CodexRunResult,
proposeViaCodex,
} from "../agent/codex-cli";
Expand Down Expand Up @@ -49,11 +50,14 @@ const stubCodex = (result: CodexRunResult): CodexCli => ({
describe("buildCodexInstructions", () => {
const instructions = buildCodexInstructions();

test("inlines all four skills", () => {
expect(instructions).toContain("=== skill: funfa ===");
test("inlines the three sibling skills, with the funfa loop as the body", () => {
// funfa's body is the provider-neutral loop, not a `=== skill: funfa ===`
// section; the three siblings are inlined for codex to re-read.
expect(instructions).not.toContain("=== skill: funfa ===");
expect(instructions).toContain("=== skill: hardware ===");
expect(instructions).toContain("=== skill: game-research ===");
expect(instructions).toContain("=== skill: plan ===");
expect(instructions).toContain("### PHASE 0");
});

test("overrides funfa's tools with the codex execution model", () => {
Expand Down Expand Up @@ -97,6 +101,35 @@ describe("buildCodexPrompt", () => {
});

describe("proposeViaCodex", () => {
test("hands codex the loop instructions and the game as the user prompt", async () => {
let captured: CodexRunInput | null = null;
const codex: CodexCli = {
detect: () => Promise.resolve({ available: true, model: "gpt-5.5" }),
run: (input) => {
captured = input;
return Promise.resolve({
appid: "48190",
markdown: SAMPLE_MD,
ok: true,
});
},
};
const { deps } = recordingDeps();
await Effect.runPromise(
proposeViaCodex({
codex,
gameName: "AC Brotherhood",
model: "gpt-5.5",
submitPlanDeps: deps,
})
);
expect(captured).not.toBeNull();
const input = captured as CodexRunInput | null;
expect(input?.developerInstructions).toContain("=== skill: hardware ===");
expect(input?.developerInstructions).toContain("Do NOT write any files");
expect(input?.userPrompt).toContain("Game: AC Brotherhood");
});

test("happy path runs the consent gate and returns an applied outcome", async () => {
const { calls, deps } = recordingDeps();
const outcome = await Effect.runPromise(
Expand Down
80 changes: 80 additions & 0 deletions apps/cli/src/__tests__/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test";
import {
API_BINDING,
buildLoopPrompt,
CODEX_BINDING,
LOOP_BODY,
} from "../agent/prompt";
import { type SkillMetadata, skillsMetadata } from "../skills/registry";

const STUB_SKILLS: readonly SkillMetadata[] = [
{ description: "hw", name: "hardware", path: "apps/cli/src/skills/hardware" },
{
description: "gr",
name: "game-research",
path: "apps/cli/src/skills/game-research",
},
{ description: "pl", name: "plan", path: "apps/cli/src/skills/plan" },
];

const apiPrompt = buildLoopPrompt({
binding: API_BINDING,
inlineSkills: false,
skills: STUB_SKILLS,
});
const codexPrompt = buildLoopPrompt({
binding: CODEX_BINDING,
inlineSkills: true,
skills: skillsMetadata,
});

describe("buildLoopPrompt — drift guard", () => {
test("both providers embed the identical loop body exactly once", () => {
expect(apiPrompt).toContain(LOOP_BODY);
expect(codexPrompt).toContain(LOOP_BODY);
// Exactly once — guards against re-inlining funfa as a `=== skill ===` block.
expect(apiPrompt.split(LOOP_BODY)).toHaveLength(2);
expect(codexPrompt.split(LOOP_BODY)).toHaveLength(2);
});

test("the loop body is provider-neutral — no tool names leak into it", () => {
expect(LOOP_BODY).not.toContain("run_shell");
expect(LOOP_BODY).not.toContain("web_search");
expect(LOOP_BODY).not.toContain("fetch_page");
expect(LOOP_BODY).not.toContain("submit_plan");
expect(LOOP_BODY).not.toContain("load_skill");
expect(LOOP_BODY).not.toContain("## Tools");
});
});

describe("buildLoopPrompt — bindings", () => {
test("the API binding names the five concrete tools", () => {
expect(API_BINDING).toContain("load_skill");
expect(API_BINDING).toContain("run_shell");
expect(API_BINDING).toContain("web_search");
expect(API_BINDING).toContain("fetch_page");
expect(API_BINDING).toContain("submit_plan");
});

test("the codex binding maps verbs to codex's own harness", () => {
expect(CODEX_BINDING).toContain("YOUR OWN shell");
expect(CODEX_BINDING).toContain("YOUR OWN web search");
expect(CODEX_BINDING).toContain("Do NOT write any files");
expect(CODEX_BINDING).toContain(
'{"appid": string, "plan_markdown": string}'
);
});
});

describe("buildLoopPrompt — skills section", () => {
test("API path lists siblings lazily; codex path inlines them", () => {
expect(apiPrompt).toContain("## Skills");
expect(apiPrompt).toContain("- hardware:");
expect(apiPrompt).not.toContain("=== skill: hardware ===");

expect(codexPrompt).toContain("=== skill: hardware ===");
expect(codexPrompt).toContain("=== skill: game-research ===");
expect(codexPrompt).toContain("=== skill: plan ===");
expect(codexPrompt).not.toContain("=== skill: funfa ===");
});
});
16 changes: 8 additions & 8 deletions apps/cli/src/__tests__/propose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ import { describe, expect, test } from "bun:test";
import { APICallError } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { Cause, Effect, Exit } from "effect";
import {
AgentError,
buildSystemPrompt,
buildUserPrompt,
propose,
} from "../agent/propose";
import { API_BINDING, buildLoopPrompt } from "../agent/prompt";
import { AgentError, buildUserPrompt, propose } from "../agent/propose";
import type { Sandbox } from "../agent/sandbox";
import type {
SubmitPlanDeps,
Expand Down Expand Up @@ -143,8 +139,12 @@ describe("propose — happy path", () => {
});

describe("propose — prompt composition", () => {
test("system prompt contains the funfa skill but not the sibling skill bodies", () => {
const prompt = buildSystemPrompt();
test("system prompt contains the funfa loop + binding but not the sibling skill bodies", () => {
const prompt = buildLoopPrompt({
binding: API_BINDING,
inlineSkills: false,
skills: [],
});
expect(prompt).toContain("submit_plan");
expect(prompt).toContain("load_skill");
expect(prompt).not.toContain("## /hardware skill");
Expand Down
Loading
Loading