diff --git a/.env.example b/.env.example index a9e82b4..ac9d8a1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ # Fizzy API FIZZY_API_TOKEN=fz_your_token_here FIZZY_ACCOUNT_SLUG=your_account_slug +FIZZY_API_URL=https://app.fizzy.do # Agent backends (provide at least one) ANTHROPIC_API_KEY=sk-ant-your-key-here diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml new file mode 100644 index 0000000..af7c23e --- /dev/null +++ b/.github/workflows/build-package.yml @@ -0,0 +1,44 @@ +name: Build package asset + +on: + push: + branches: + - self-hosted + - main + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Pack npm tarball + run: npm pack + + - name: Upload package artifact + uses: actions/upload-artifact@v7 + with: + name: fizzy-popper-package + path: fizzy-popper-*.tgz + if-no-files-found: error diff --git a/README.md b/README.md index fd24934..0f94bec 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ This is the same approach OpenAI took with Symphony: spec first, implementation │ Agent │ │ Agent │ │ #agent- │ │ #agent- │ │ instructions│ │ instructions│ - │ #claude │ │ #anthropic │ + │ #codex │ │ #anthropic │ │ #move-to- │ │ #close-on- │ │ code-review │ │ complete │ └──────────────┘ └──────────────┘ @@ -54,15 +54,15 @@ You need three things: a board, a golden ticket card, and a work card to test wi ```bash # Create a board with columns -fizzy board create --title "Agent Playground" -fizzy column create --board BOARD_ID --title "Triage" -fizzy column create --board BOARD_ID --title "Done" +fizzy board create --name "Agent Playground" +fizzy column create --board BOARD_ID --name "Triage" +fizzy column create --board BOARD_ID --name "Done" # Create a golden ticket in the Triage column fizzy card create --board BOARD_ID --title "Triage Agent" \ --description "Summarize the card and propose a plan of action as a bulleted list." fizzy card tag CARD_NUMBER --tag agent-instructions -fizzy card tag CARD_NUMBER --tag claude +fizzy card tag CARD_NUMBER --tag codex fizzy card tag CARD_NUMBER --tag move-to-done fizzy card column CARD_NUMBER --column TRIAGE_COLUMN_ID @@ -77,9 +77,9 @@ fizzy card create --board BOARD_ID --title "Add user authentication" \ fizzy card column CARD_NUMBER --column TRIAGE_COLUMN_ID ``` -**Or in the Fizzy UI:** Create a card, tag it `#agent-instructions` and `#claude`, write your prompt in the description, add checklist items as steps, and drag it into the column you want to automate. Then drag a work card into that column and watch the agent go. +**Or in the Fizzy UI:** Create a card, tag it `#agent-instructions` plus a backend tag like `#codex` or `#claude`, write your prompt in the description, add checklist items as steps, and drag it into the column you want to automate. Then drag a work card into that column and watch the agent go. -**With Claude Code:** Run `/setup-test-board` — there's a built-in skill that walks you through the whole thing using the Fizzy CLI. +**With an agent CLI:** Run `/setup-test-board` — there's a built-in skill that walks you through the whole thing using the Fizzy CLI. Use `#codex` for Codex, `#claude` for Claude Code, or another supported backend tag. ## Golden tickets @@ -153,6 +153,11 @@ webhook: backends: claude: model: sonnet + codex: + model: gpt-5.5 + args: + - --sandbox + - danger-full-access anthropic: api_key: $ANTHROPIC_API_KEY model: claude-sonnet-4-20250514 diff --git a/skills/setup-test-board.md b/skills/setup-test-board.md index c0b68c0..6fb07e1 100644 --- a/skills/setup-test-board.md +++ b/skills/setup-test-board.md @@ -6,16 +6,16 @@ Help me set up a test board on Fizzy to dogfood fizzy-popper. Walk me through it Here's what we need: -1. **Create a board** (or pick an existing one). Use `fizzy board list` to show what's available, then `fizzy board create --title "Agent Playground"` if we need a new one. +1. **Create a board** (or pick an existing one). Use `fizzy board list` to show what's available, then `fizzy board create --name "Agent Playground"` if we need a new one. 2. **Create columns**. We need at least two — one for the agent to watch and one for cards to land in after processing. For example: - - `fizzy column create --board BOARD_ID --title "Triage"` - - `fizzy column create --board BOARD_ID --title "Done"` + - `fizzy column create --board BOARD_ID --name "Triage"` + - `fizzy column create --board BOARD_ID --name "Done"` 3. **Create a golden ticket** — this is the card that tells fizzy-popper what to do in a column. It needs: - A title like "Triage Agent" - A description with the agent's instructions (the prompt) - - Tags: `#agent-instructions` (required), a backend tag like `#claude`, and a completion tag like `#move-to-done` + - Tags: `#agent-instructions` (required), a backend tag like `#codex` or `#claude`, and a completion tag like `#move-to-done` - Placed in the column it configures - Optionally, steps (checklist items) the agent should follow @@ -23,7 +23,7 @@ Here's what we need: fizzy card create --board BOARD_ID --title "Triage Agent" \ --description "Summarize the card and propose a plan of action." fizzy card tag CARD_NUMBER --tag agent-instructions - fizzy card tag CARD_NUMBER --tag claude + fizzy card tag CARD_NUMBER --tag codex fizzy card tag CARD_NUMBER --tag move-to-done fizzy card column CARD_NUMBER --column COLUMN_ID fizzy step create CARD_NUMBER --content "Acknowledge the request" diff --git a/src/agent.ts b/src/agent.ts index 2179600..5584b5e 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -30,6 +30,58 @@ export interface AgentBackend { execute(prompt: string, options: BackendOptions): Promise } +export function parseCodexOutput(raw: string): string { + try { + const parsed = JSON.parse(raw) + if (typeof parsed.output === "string") return parsed.output + if (typeof parsed.result === "string") return parsed.result + return raw + } catch { /* not a single JSON object */ } + + let finalMessage: string | null = null + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed.startsWith("{")) continue + + try { + const event = JSON.parse(trimmed) + if ( + event.type === "item.completed" && + event.item?.type === "agent_message" && + typeof event.item.text === "string" + ) { + finalMessage = event.item.text + } + } catch { /* ignore non-JSON log lines */ } + } + + return finalMessage ?? raw +} + +export function formatBackendError(err: unknown): string { + if (err && typeof err === "object") { + const data = err as Record + const concise = data.shortMessage ?? data.originalMessage + if (typeof concise === "string" && concise.trim()) return concise.trim() + + const command = typeof data.command === "string" ? data.command : "" + const timedOut = data.timedOut === true + const durationMs = typeof data.durationMs === "number" ? data.durationMs : undefined + if (timedOut && command) { + const duration = durationMs === undefined ? "" : ` after ${durationMs} milliseconds` + return `Command timed out${duration}: ${command}` + } + + const exitCode = data.exitCode + if (command && (typeof exitCode === "number" || typeof exitCode === "string")) { + return `Command failed with exit code ${exitCode}: ${command}` + } + } + + const message = err instanceof Error ? err.message : String(err) + return message.split(/\r?\n/)[0].trim() +} + // ── Prompt builder ── export function buildPrompt( @@ -132,7 +184,7 @@ class ClaudeBackend implements AgentBackend { metadata: { duration_ms: Date.now() - start }, } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) + const message = formatBackendError(err) return { output: "", success: false, error: message } } } @@ -142,9 +194,11 @@ class ClaudeBackend implements AgentBackend { class CodexBackend implements AgentBackend { name = "codex" private model: string + private args: string[] constructor(config: Config) { this.model = config.backends?.codex?.model ?? "codex-mini" + this.args = config.backends?.codex?.args ?? [] } async execute(prompt: string, options: BackendOptions): Promise { @@ -153,21 +207,26 @@ class CodexBackend implements AgentBackend { try { const result = await execa( "codex", - ["exec", "--model", model, "--json", "--ephemeral", prompt], - { timeout: options.timeout, cancelSignal: options.signal }, + [ + "exec", + ...this.args, + "--model", + model, + "--json", + "--ephemeral", + "--cd", + process.cwd(), + ], + { input: prompt, timeout: options.timeout, cancelSignal: options.signal }, ) - let output = result.stdout - try { - const parsed = JSON.parse(output) - output = parsed.output ?? parsed.result ?? output - } catch { /* not JSON, use raw stdout */ } + const output = parseCodexOutput(result.stdout) return { output, success: true, metadata: { duration_ms: Date.now() - start }, } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) + const message = formatBackendError(err) return { output: "", success: false, error: message } } } @@ -197,7 +256,7 @@ class OpenCodeBackend implements AgentBackend { metadata: { duration_ms: Date.now() - start }, } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) + const message = formatBackendError(err) return { output: "", success: false, error: message } } } @@ -238,7 +297,7 @@ class AnthropicBackend implements AgentBackend { }, } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) + const message = formatBackendError(err) return { output: "", success: false, error: message } } } @@ -276,7 +335,7 @@ class OpenAIBackend implements AgentBackend { }, } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) + const message = formatBackendError(err) return { output: "", success: false, error: message } } } @@ -313,7 +372,7 @@ class CommandBackend implements AgentBackend { metadata: { duration_ms: Date.now() - start }, } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) + const message = formatBackendError(err) return { output: "", success: false, error: message } } finally { try { rmSync(tempDir, { recursive: true }) } catch { /* ignore */ } diff --git a/src/config.ts b/src/config.ts index ca6ebdb..5a4025f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ const BackendConfigSchema = z.object({ }).optional(), codex: z.object({ model: z.string().default("codex-mini"), + args: z.array(z.string()).default([]), }).optional(), opencode: z.object({}).optional(), anthropic: z.object({ diff --git a/src/setup.ts b/src/setup.ts index 7e5337b..8e987d1 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,11 +1,16 @@ import * as p from "@clack/prompts" import chalk from "chalk" +import { execFileSync } from "node:child_process" import { saveConfig } from "./config.js" import { FizzyClient, type FizzyBoard } from "./fizzy.js" import { detectBackends } from "./agent.js" +const DEFAULT_FIZZY_API_URL = "https://app.fizzy.do" + export async function runSetup(): Promise { p.intro(chalk.bold("fizzy-popper") + " — AI agents for your Fizzy boards") + const apiUrl = resolveFizzyApiUrl() + p.log.info(`Fizzy API: ${apiUrl}`) // API token const token = await p.text({ @@ -19,7 +24,7 @@ export async function runSetup(): Promise { // Validate token and get accounts const tempClient = new FizzyClient({ - fizzy: { token: token as string, account: "", api_url: "https://app.fizzy.do" }, + fizzy: { token: token as string, account: "", api_url: apiUrl }, } as any) let identity: Awaited> @@ -55,7 +60,7 @@ export async function runSetup(): Promise { // Fetch boards const client = new FizzyClient({ - fizzy: { token: token as string, account: accountSlug, api_url: "https://app.fizzy.do" }, + fizzy: { token: token as string, account: accountSlug, api_url: apiUrl }, } as any) let boards: FizzyBoard[] @@ -108,7 +113,7 @@ export async function runSetup(): Promise { fizzy: { token: token as string, account: accountSlug, - api_url: "https://app.fizzy.do", + api_url: apiUrl, }, boards: selectedBoards as string[], agent: { @@ -144,3 +149,37 @@ function cancel(): void { p.cancel("Setup cancelled.") process.exit(0) } + +export function resolveFizzyApiUrl(): string { + const envUrl = normalizeFizzyApiUrl(process.env.FIZZY_API_URL) + if (envUrl) return envUrl + + try { + const stdout = execFileSync("fizzy", ["config", "show", "--json"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }) + const parsed = JSON.parse(stdout) as { data?: { api_url?: unknown } } + const apiUrl = normalizeFizzyApiUrl(parsed.data?.api_url) + if (apiUrl) return apiUrl + } catch { + // If the Fizzy CLI is unavailable or unauthenticated, fall back to hosted Fizzy. + } + + return DEFAULT_FIZZY_API_URL +} + +function normalizeFizzyApiUrl(raw: unknown): string | null { + if (typeof raw !== "string") return null + const apiUrl = raw.trim().replace(/\/+$/, "") + if (!apiUrl) return null + + try { + const parsed = new URL(apiUrl) + if (parsed.protocol === "http:" || parsed.protocol === "https:") return apiUrl + } catch { + // Ignore invalid URLs and fall back to the next configured source. + } + + return null +} diff --git a/test/agent.test.ts b/test/agent.test.ts index 289d5de..49b9a78 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest" -import { buildPrompt, createBackend } from "../src/agent.js" +import { buildPrompt, createBackend, formatBackendError, parseCodexOutput } from "../src/agent.js" import { makeCard, makeComment, makeGoldenTicket, makeConfig } from "./fixtures.js" describe("buildPrompt", () => { @@ -164,3 +164,44 @@ describe("createBackend", () => { expect(() => createBackend("command", makeConfig())).toThrow("Command backend requires") }) }) + +describe("parseCodexOutput", () => { + it("extracts the final agent message from Codex JSONL", () => { + const raw = [ + "Reading prompt from stdin...", + JSON.stringify({ type: "thread.started", thread_id: "t1" }), + JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "

Done.

" } }), + JSON.stringify({ type: "turn.completed" }), + ].join("\n") + + expect(parseCodexOutput(raw)).toBe("

Done.

") + }) + + it("keeps legacy single-object output support", () => { + expect(parseCodexOutput(JSON.stringify({ output: "

Legacy.

" }))).toBe("

Legacy.

") + }) + + it("does not return non-string single-object output", () => { + const raw = JSON.stringify({ output: { text: "

Object.

" } }) + + expect(parseCodexOutput(raw)).toBe(raw) + }) +}) + +describe("formatBackendError", () => { + it("uses execa shortMessage without raw stdout JSONL", () => { + const error = { + shortMessage: "Command timed out after 300000 milliseconds: codex exec --json", + stdout: [ + "Reading prompt from stdin...", + JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "working" } }), + ].join("\n"), + } + + expect(formatBackendError(error)).toBe("Command timed out after 300000 milliseconds: codex exec --json") + }) + + it("keeps only the first line for generic errors", () => { + expect(formatBackendError(new Error("first line\nsecond line with JSONL"))).toBe("first line") + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts index 40cbd67..14e211d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -158,12 +158,15 @@ describe("config", () => { fizzy: { token: "fz_t", account: "a" }, backends: { claude: { model: "opus" }, + codex: { model: "gpt-5.5", args: ["--sandbox", "danger-full-access"] }, command: { run: "my-script {prompt_file}" }, }, }) const config = loadConfig(tempDir) expect(config.backends.claude?.model).toBe("opus") + expect(config.backends.codex?.model).toBe("gpt-5.5") + expect(config.backends.codex?.args).toEqual(["--sandbox", "danger-full-access"]) expect(config.backends.command?.run).toBe("my-script {prompt_file}") }) }) diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..6f1cfec --- /dev/null +++ b/test/setup.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, afterEach } from "vitest" +import { execFileSync } from "node:child_process" +import { resolveFizzyApiUrl } from "../src/setup.js" + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), +})) + +const mockedExecFileSync = vi.mocked(execFileSync) + +describe("resolveFizzyApiUrl", () => { + afterEach(() => { + delete process.env.FIZZY_API_URL + vi.clearAllMocks() + }) + + it("uses FIZZY_API_URL when set", () => { + process.env.FIZZY_API_URL = "https://fizzy.example.test/" + + expect(resolveFizzyApiUrl()).toBe("https://fizzy.example.test") + expect(mockedExecFileSync).not.toHaveBeenCalled() + }) + + it("uses the effective api_url from the Fizzy CLI", () => { + mockedExecFileSync.mockReturnValue( + JSON.stringify({ ok: true, data: { api_url: "https://fizzy.joshyorko.com" } }), + ) + + expect(resolveFizzyApiUrl()).toBe("https://fizzy.joshyorko.com") + expect(mockedExecFileSync).toHaveBeenCalledWith( + "fizzy", + ["config", "show", "--json"], + expect.objectContaining({ encoding: "utf-8" }), + ) + }) + + it("ignores non-string api_url values from the Fizzy CLI", () => { + mockedExecFileSync.mockReturnValue( + JSON.stringify({ ok: true, data: { api_url: { href: "https://fizzy.example.test" } } }), + ) + + expect(resolveFizzyApiUrl()).toBe("https://app.fizzy.do") + }) + + it("ignores invalid api_url values from the Fizzy CLI", () => { + mockedExecFileSync.mockReturnValue( + JSON.stringify({ ok: true, data: { api_url: "not a url" } }), + ) + + expect(resolveFizzyApiUrl()).toBe("https://app.fizzy.do") + }) + + it("falls back to hosted Fizzy when CLI config cannot be read", () => { + mockedExecFileSync.mockImplementation(() => { + throw new Error("missing fizzy cli") + }) + + expect(resolveFizzyApiUrl()).toBe("https://app.fizzy.do") + }) +})