@firefunc-agent/runner 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,65 @@
1
+ FireFunc Runner — Client Software Licence
2
+ Copyright (c) 2026 FireFunc. All rights reserved.
3
+
4
+ This licence covers ONLY the `@firefunc/runner` package (the "Runner") — the
5
+ client daemon that runs on your own machine, invokes coding agents you have
6
+ installed and licensed yourself, and connects to the FireFunc service.
7
+
8
+ It does NOT cover the FireFunc service, its API, its web applications, its
9
+ infrastructure, or any other part of the FireFunc source, all of which remain
10
+ proprietary and are governed by the LICENSE file at the root of the FireFunc
11
+ repository. Nothing here grants any right to that software.
12
+
13
+ 1. GRANT
14
+
15
+ You may download, install, and run the Runner, on any number of machines you
16
+ own or control, free of charge, for the purpose of connecting those machines
17
+ to the FireFunc service.
18
+
19
+ You may run it against a FireFunc account you are authorised to use. No
20
+ licence key, subscription, or payment is required to run the Runner itself.
21
+
22
+ 2. RESTRICTIONS
23
+
24
+ You may not:
25
+
26
+ (a) modify the Runner, or create derivative works of it;
27
+ (b) redistribute it, whether modified or unmodified, including by
28
+ republishing it to a package registry under any name;
29
+ (c) reverse engineer, decompile, or disassemble it, except to the extent
30
+ that applicable law expressly permits despite this restriction;
31
+ (d) remove or alter this licence, any copyright notice, or any attribution
32
+ it contains;
33
+ (e) use it to access, probe, or interfere with the FireFunc service other
34
+ than as its published interfaces intend.
35
+
36
+ Installing the Runner as a dependency of your own project, and running it in
37
+ your own automation, is normal use and is not redistribution.
38
+
39
+ 3. THIRD-PARTY AGENTS
40
+
41
+ The Runner invokes coding agents — Claude Code, OpenAI Codex, Gemini CLI,
42
+ Cursor CLI and others — that it does not include and does not license to you.
43
+ You install those yourself and use them under their own terms, billed to your
44
+ own accounts. FireFunc neither resells nor proxies them.
45
+
46
+ 4. YOUR CODE
47
+
48
+ The Runner executes on your machine, against your repositories, using your
49
+ credentials. FireFunc does not require, and the Runner does not transmit,
50
+ your source code to the FireFunc service in order to function.
51
+
52
+ 5. NO WARRANTY
53
+
54
+ THE RUNNER IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
55
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
56
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
57
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
58
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
59
+ FROM, OUT OF, OR IN CONNECTION WITH THE RUNNER OR ITS USE.
60
+
61
+ 6. TERMINATION
62
+
63
+ This licence terminates automatically if you breach section 2. On
64
+ termination you must stop using the Runner and delete your copies. Sections
65
+ 5 and 6 survive.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # firefunc-runner
2
+
3
+ Run **your own** Claude Code on bugs FireFunc routes to you — and open PRs automatically.
4
+
5
+ This is the **self-hosted runner** for FireFunc's _Claude Code Channel_ engine. It's a tiny always-on daemon on your machine (laptop, dev box, CI host) that:
6
+
7
+ 1. **long-polls FireFunc** (outbound only — works behind NAT/VPN, no inbound ports),
8
+ 2. **claims** a bug FireFunc dispatched to your project,
9
+ 3. runs **your local Claude Code** (`claude -p`) in an isolated git worktree off a clean base branch,
10
+ 4. opens a **draft PR** and reports the result back.
11
+
12
+ **Why this and not the cloud Routine?** No ~15/day cap (it uses _your_ Claude subscription's limits), your **code never leaves your machine**, and it has your full local context — running app, AWS creds, `gh`/`git` auth, MCP servers, `CLAUDE.md`.
13
+
14
+ ## Requirements
15
+
16
+ - Node ≥ 20
17
+ - [Claude Code](https://code.claude.com) installed and signed in (`claude` on your PATH)
18
+ - `git` and the [GitHub CLI](https://cli.github.com) (`gh`) signed in, with push access to the repos
19
+ - A local checkout of each repo FireFunc will fix
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ # Install once (or skip it and use npx, as below).
25
+ npm install -g @firefunc-agent/runner
26
+
27
+ # 1. In FireFunc: Engines → "Add a machine". Copy the WHOLE command it shows —
28
+ # it already contains your one-time token (ffr_…) and your API URL.
29
+
30
+ # 2. On your machine — the copied command looks like this:
31
+ npx @firefunc-agent/runner connect ffr_xxxxx --api https://your-firefunc-host --repos-dir ~/code
32
+ # (or map a repo exactly: --repo acme/api=/Users/me/code/api)
33
+
34
+ # 3. Go online:
35
+ npx @firefunc-agent/runner start
36
+ ```
37
+
38
+ `--api` is required: this package has no hard-coded FireFunc host, so it only
39
+ ever talks to the deployment you point it at.
40
+
41
+ That's it. When your QA files a bug (labeled for FireFunc), it appears here within seconds, gets fixed, and a draft PR shows up — all visible in the FireFunc dashboard.
42
+
43
+ ## Safety
44
+
45
+ - Works in a throwaway **git worktree** off a clean base — it **never touches your working tree or current branch**.
46
+ - Opens a PR **only when there's a real diff** (no phantom PRs).
47
+ - Pushes a **deterministic branch** with `--force-with-lease`, so a duplicate dispatch converges to one PR.
48
+ - Runs Claude with `--permission-mode acceptEdits` (autonomous file edits) **without** `--dangerously-skip-permissions` (which hangs non-interactive daemons).
49
+ - A per-job timeout (default 20 min) prevents a stuck run from hanging the daemon.
50
+
51
+ ## Commands
52
+
53
+ | Command | What it does |
54
+ | ----------------------------------------- | ------------------------------------------------------------------------ |
55
+ | `firefunc-runner connect <token> [flags]` | Save your token + settings (`~/.firefunc-runner/config.json`, mode 0600) |
56
+ | `firefunc-runner start` | Start the always-on daemon |
57
+ | `firefunc-runner status` | Show the saved config |
58
+
59
+ ### `connect` flags
60
+
61
+ | Flag | Default | Meaning |
62
+ | --------------------------- | ---------------- | -------------------------------------------------------------- |
63
+ | `--api <url>` | — (**required**) | Your FireFunc API URL — the copied command fills this in |
64
+ | `--name <name>` | hostname | Friendly runner name (shown in the dashboard) |
65
+ | `--repos-dir <dir>` | — | Base dir; repos are matched by name (`acme/api` → `<dir>/api`) |
66
+ | `--repo <owner/repo=/path>` | — | Map one repo to an exact checkout (repeatable) |
67
+ | `--claude-bin <bin>` | `claude` | Claude Code binary |
68
+
69
+ ## Run it as a service
70
+
71
+ Keep it alive across reboots with launchd (macOS), systemd (Linux), or `pm2`:
72
+
73
+ ```bash
74
+ pm2 start "npx @firefunc-agent/runner start" --name firefunc-runner
75
+ pm2 save
76
+ ```
77
+
78
+ ## Licence
79
+
80
+ `@firefunc-agent/runner` is the **client** half of FireFunc, published so the machines
81
+ that do the work can actually install it. It is free to download and run against
82
+ a FireFunc account you are authorised to use — see [LICENSE](./LICENSE).
83
+
84
+ The FireFunc service itself (API, workers, dashboard, infrastructure) is
85
+ proprietary and is not distributed here. This package contains only the daemon
86
+ that runs on your machine: it invokes coding agents **you** installed and pay
87
+ for, works on **your** checkouts, and reports results back. Your source code
88
+ does not need to reach FireFunc for any of it to work.
package/dist/api.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ export type ClaimedJob = {
2
+ runId: string;
3
+ workItemId: string;
4
+ prompt: string;
5
+ repo: string | null;
6
+ branch: string | null;
7
+ externalId: string | null;
8
+ title: string | null;
9
+ type: string;
10
+ engine?: string | null;
11
+ model?: string | null;
12
+ effort?: string | null;
13
+ permissionMode?: string | null;
14
+ attachments?: {
15
+ filename?: string | null;
16
+ contentType?: string | null;
17
+ dataB64: string;
18
+ }[];
19
+ };
20
+ export type RunnerEvent = 'cloning' | 'fixing' | 'pushing' | 'pr_opened' | 'heartbeat' | 'log';
21
+ export type ResultPayload = {
22
+ status: 'pr_opened';
23
+ pr: {
24
+ repo?: string;
25
+ number?: number;
26
+ url?: string;
27
+ branch?: string;
28
+ summary?: string;
29
+ };
30
+ } | {
31
+ status: 'no_pr';
32
+ error?: string;
33
+ } | {
34
+ status: 'failed';
35
+ error: string;
36
+ };
37
+ export declare class RunnerApi {
38
+ private readonly apiUrl;
39
+ private readonly token;
40
+ constructor(apiUrl: string, token: string);
41
+ private headers;
42
+ claim(meta: {
43
+ version: string;
44
+ hostname: string;
45
+ platform: string;
46
+ viewerPort?: number | null;
47
+ engines?: string[];
48
+ budget?: Array<{
49
+ engine: string;
50
+ planLabel?: string;
51
+ windows: Array<{
52
+ window: string;
53
+ pctUsed: number;
54
+ resetsAt: string | null;
55
+ }>;
56
+ }>;
57
+ }): Promise<ClaimedJob | null>;
58
+ repos(): Promise<string[]>;
59
+ config(): Promise<{
60
+ maxParallel: number;
61
+ } | null>;
62
+ event(runId: string, event: RunnerEvent, detail?: Record<string, unknown>): Promise<void>;
63
+ result(runId: string, payload: ResultPayload): Promise<void>;
64
+ }
package/dist/api.js ADDED
@@ -0,0 +1,76 @@
1
+ export class RunnerApi {
2
+ apiUrl;
3
+ token;
4
+ constructor(apiUrl, token) {
5
+ this.apiUrl = apiUrl;
6
+ this.token = token;
7
+ this.apiUrl = apiUrl.replace(/\/+$/, '');
8
+ }
9
+ headers() {
10
+ return { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' };
11
+ }
12
+ async claim(meta) {
13
+ const ctrl = new AbortController();
14
+ const timer = setTimeout(() => ctrl.abort(), 35_000);
15
+ try {
16
+ const res = await fetch(`${this.apiUrl}/channel/claim`, {
17
+ method: 'POST',
18
+ headers: this.headers(),
19
+ body: JSON.stringify(meta),
20
+ signal: ctrl.signal,
21
+ });
22
+ if (res.status === 204)
23
+ return null;
24
+ if (res.status === 401)
25
+ throw new Error('runner token rejected (revoked or wrong API URL)');
26
+ if (!res.ok)
27
+ throw new Error(`claim failed: ${res.status}`);
28
+ const body = (await res.json());
29
+ return body.job ?? null;
30
+ }
31
+ finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+ async repos() {
36
+ try {
37
+ const res = await fetch(`${this.apiUrl}/channel/repos`, { headers: this.headers() });
38
+ if (!res.ok)
39
+ return [];
40
+ const body = (await res.json());
41
+ return Array.isArray(body.repos) ? body.repos : [];
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ async config() {
48
+ try {
49
+ const res = await fetch(`${this.apiUrl}/channel/config`, { headers: this.headers() });
50
+ if (!res.ok)
51
+ return null;
52
+ const body = (await res.json());
53
+ return { maxParallel: typeof body.maxParallel === 'number' ? body.maxParallel : 1 };
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ async event(runId, event, detail) {
60
+ await fetch(`${this.apiUrl}/channel/runs/${runId}/events`, {
61
+ method: 'POST',
62
+ headers: this.headers(),
63
+ body: JSON.stringify({ event, detail }),
64
+ }).catch(() => { });
65
+ }
66
+ async result(runId, payload) {
67
+ const res = await fetch(`${this.apiUrl}/channel/runs/${runId}/result`, {
68
+ method: 'POST',
69
+ headers: this.headers(),
70
+ body: JSON.stringify(payload),
71
+ });
72
+ if (!res.ok)
73
+ throw new Error(`result report failed: ${res.status}`);
74
+ }
75
+ }
76
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1,2 @@
1
+ import type { RunnerConfig } from './config.js';
2
+ export declare function runBrief(repo: string, cfg: RunnerConfig): Promise<void>;
package/dist/brief.js ADDED
@@ -0,0 +1,138 @@
1
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { exec, execOut } from './exec.js';
5
+ import { resolveRepoPath } from './job.js';
6
+ function out(msg) {
7
+ process.stdout.write(`${msg}\n`);
8
+ }
9
+ const BRIEF_PROMPT = `You are bootstrapping a CLAUDE.md "runner briefing" for THIS repository.
10
+
11
+ Context: a headless, unattended FireFunc runner will load this file on every automated fix — no human to guide it, no prior context, a cold boot each time. Claude Code auto-loads a repo-root CLAUDE.md as project instructions, so this file is the runner's complete, always-loaded operating manual.
12
+
13
+ If a CLAUDE.md already exists at the repo root, only improve it where it is clearly incomplete; otherwise WRITE a new one at the repo root (path: CLAUDE.md). Include, concisely:
14
+ - How to install dependencies and run the app locally (the exact commands).
15
+ - The test / lint / build commands that gate CI — explicitly called out as the signal a fix must keep green, plus "add or adjust a test".
16
+ - A short system map: the main components and the key end-to-end flows.
17
+ - Code conventions a fix must respect (logging, error handling, naming).
18
+ - Hard rules / "tread carefully — prefer human review" paths: auth, payments, DB migrations, CI config (.github/workflows), deploy/terraform, secrets.
19
+ - A "how to make a good autonomous fix" checklist: localize to the smallest correct change, add a test, run the gate locally, keep the diff reviewable (it ships as a draft PR).
20
+
21
+ CRITICAL: state only what you can VERIFY from the code — read package.json scripts, CI workflows, and the source before asserting a command or convention. Do not hallucinate. Do NOT modify any file other than CLAUDE.md.`;
22
+ export async function runBrief(repo, cfg) {
23
+ const repoPath = resolveRepoPath(repo, cfg);
24
+ if (!repoPath) {
25
+ out(`No local checkout for ${repo}. Map it first: firefunc-runner repo ${repo} <path>`);
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ const claudeBin = cfg.claudeBin ?? 'claude';
30
+ const branch = 'claude/firefunc-claude-md';
31
+ let worktree = null;
32
+ try {
33
+ out(`Briefing ${repo} — checking out a throwaway worktree…`);
34
+ await exec('git', ['-C', repoPath, 'fetch', 'origin', '--quiet'], { timeoutMs: 120_000 });
35
+ let base = cfg.baseBranch ?? '';
36
+ if (!base) {
37
+ const head = await execOut('git', ['-C', repoPath, 'symbolic-ref', 'refs/remotes/origin/HEAD'], repoPath);
38
+ base = head ? head.replace('refs/remotes/origin/', '') : 'main';
39
+ }
40
+ worktree = mkdtempSync(join(tmpdir(), 'firefunc-brief-'));
41
+ const add = await exec('git', ['-C', repoPath, 'worktree', 'add', '--force', '-B', branch, worktree, `origin/${base}`], { timeoutMs: 120_000 });
42
+ if (add.code !== 0) {
43
+ out(`git worktree add failed: ${add.stderr.slice(0, 300)}`);
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ if (existsSync(join(worktree, 'CLAUDE.md'))) {
48
+ out('This repo already has a CLAUDE.md — Claude will only fill gaps, if any.');
49
+ }
50
+ const runEnv = {
51
+ ...cfg.claudeEnv,
52
+ ...(cfg.claudeCodeOAuthToken ? { CLAUDE_CODE_OAUTH_TOKEN: cfg.claudeCodeOAuthToken } : {}),
53
+ ...(cfg.claudeUseApiKey ? {} : { ANTHROPIC_API_KEY: undefined }),
54
+ };
55
+ const model = cfg.model ?? 'opus';
56
+ const tools = cfg.allowedTools ?? 'Read Edit Write Bash Glob Grep WebFetch WebSearch Task TodoWrite';
57
+ const isWin = process.platform === 'win32';
58
+ const args = [
59
+ '-p',
60
+ ...(isWin ? [] : [BRIEF_PROMPT]),
61
+ '--output-format',
62
+ 'stream-json',
63
+ '--verbose',
64
+ '--permission-mode',
65
+ 'bypassPermissions',
66
+ '--allowedTools',
67
+ tools,
68
+ '--model',
69
+ model,
70
+ ];
71
+ out('Running Claude Code to draft the briefing (this can take a few minutes)…');
72
+ const claude = await exec(claudeBin, args, {
73
+ cwd: worktree,
74
+ timeoutMs: (cfg.jobTimeoutMinutes ?? 20) * 60_000,
75
+ env: runEnv,
76
+ input: isWin ? BRIEF_PROMPT : undefined,
77
+ shell: isWin,
78
+ });
79
+ if (claude.code === 124) {
80
+ out('Claude timed out before finishing the briefing.');
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ await exec('git', ['-C', worktree, 'add', '-A']);
85
+ const diff = await exec('git', ['-C', worktree, 'diff', '--cached', '--quiet']);
86
+ if (diff.code === 0) {
87
+ out('No changes produced — the repo may already be fully briefed.');
88
+ return;
89
+ }
90
+ await exec('git', [
91
+ '-C',
92
+ worktree,
93
+ 'commit',
94
+ '-m',
95
+ 'docs: add CLAUDE.md runner briefing',
96
+ '--quiet',
97
+ ]);
98
+ const push = await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000 });
99
+ if (push.code !== 0) {
100
+ out(`git push failed: ${push.stderr.slice(0, 300)}`);
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ const body = 'Starter CLAUDE.md "runner briefing" drafted by FireFunc so the self-hosted ' +
105
+ 'runner boots every fix with full project context + guardrails.\n\n' +
106
+ 'Review and edit before merging — this file loads on every automated run.';
107
+ const create = await exec('gh', [
108
+ 'pr',
109
+ 'create',
110
+ '--draft',
111
+ '--head',
112
+ branch,
113
+ '--base',
114
+ base,
115
+ '--title',
116
+ 'docs: add CLAUDE.md runner briefing',
117
+ '--body',
118
+ body,
119
+ ], { cwd: worktree, timeoutMs: 60_000 });
120
+ let url = create.stdout.trim().split('\n').pop() ?? '';
121
+ if (create.code !== 0 || !/^https?:\/\//.test(url)) {
122
+ url = await execOut('gh', ['pr', 'view', branch, '--json', 'url', '--jq', '.url'], worktree);
123
+ }
124
+ out(`\nDraft PR opened: ${url || '(open it from GitHub — branch ' + branch + ')'}`);
125
+ out('Review the CLAUDE.md, edit anything wrong, and merge it. The runner will load it on every fix.');
126
+ }
127
+ finally {
128
+ if (worktree) {
129
+ await exec('git', ['-C', repoPath, 'worktree', 'remove', '--force', worktree]).catch(() => { });
130
+ try {
131
+ rmSync(worktree, { recursive: true, force: true });
132
+ }
133
+ catch {
134
+ }
135
+ }
136
+ }
137
+ }
138
+ //# sourceMappingURL=brief.js.map
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfig, saveConfig, configPath } from './config.js';
3
+ import { startDaemon } from './daemon.js';
4
+ import { runBrief } from './brief.js';
5
+ const HELP = `firefunc-runner — run YOUR local Claude Code on bugs FireFunc routes to you.
6
+
7
+ Usage:
8
+ firefunc-runner connect <token> [options] Save your runner token + settings
9
+ firefunc-runner repo <owner/repo> <path> Point a repo at its local checkout
10
+ firefunc-runner brief <owner/repo> Draft a CLAUDE.md runner briefing as a draft PR
11
+ firefunc-runner start Start the always-on daemon
12
+ firefunc-runner status Show the saved config
13
+ firefunc-runner --help
14
+
15
+ connect options:
16
+ --api <url> FireFunc API URL — REQUIRED. Copy the whole connect
17
+ command from the Engines screen; it fills this in.
18
+ --name <name> Friendly runner name (default: this hostname)
19
+ --repos-dir <dir> Base directory where your checkouts live (matched by repo name)
20
+ --repo <owner/repo=/path> Map one repo to an exact local checkout (repeatable)
21
+ --claude-bin <bin> Claude Code binary (default: claude)
22
+ --claude-oauth-token <tok> Subscription token from "claude setup-token". Usually NOT
23
+ needed — the runner already uses your machine's "claude" login.
24
+ Set only if this machine has no interactive Claude login.
25
+ --use-api-key Bill pay-as-you-go API credits via ANTHROPIC_API_KEY instead of
26
+ your subscription. OFF by default — the runner hides any
27
+ ANTHROPIC_API_KEY from claude so it can't silently charge the API.
28
+
29
+ engine (the runner drives whatever agent CLI each job asks for — Claude Code by
30
+ default; the per-project Engine setting in FireFunc picks one, or set a local
31
+ fallback + each engine's auth here):
32
+ --engine <id> Default engine when a job doesn't pin one:
33
+ claude_code | codex_cli | gemini_cli | cursor_cli (default claude_code)
34
+ --codex-bin <bin> OpenAI Codex binary (default: codex). Uses your "codex login"
35
+ (ChatGPT plan); API keys are hidden unless --codex-use-api-key.
36
+ --codex-use-api-key Bill OpenAI API credits via OPENAI_API_KEY instead of the ChatGPT plan.
37
+ --gemini-bin <bin> Gemini CLI binary (default: gemini).
38
+ --gemini-api-key <key> Gemini API key (Google AI Studio; free tier ~250/day).
39
+ Consumer "Login with Google" was retired for Gemini CLI in June 2026.
40
+ --cursor-bin <bin> Cursor CLI binary (default: cursor-agent).
41
+ --cursor-api-key <key> Cursor User API key (from the Cursor dashboard; bills your Cursor plan).
42
+ --quiet Hide the live Claude session feed in this terminal (default: show
43
+ it). The feed is local-only and never sent to FireFunc either way.
44
+ --viewer-port <n> Port for the local live-session web terminal (default 8787). Open
45
+ http://127.0.0.1:<n> to watch Claude work; FireFunc's "Watch live"
46
+ button opens it too. Bound to localhost only — never sent to FireFunc.
47
+ --no-viewer Don't serve the local live-session web terminal.
48
+ --max-parallel <n> Run up to N Claude Code sessions AT ONCE (default 1 = serial).
49
+ Each runs in its own git worktree + dev-server port. Higher N
50
+ = faster throughput, more CPU/RAM, and may hit Claude-Max rate
51
+ limits. It's your machine + subscription — set what you like.
52
+ --dev-server-base-port <n> Base port for parallel sessions' dev servers (default 3100).
53
+
54
+ dev-environment (so Claude can REPRODUCE bugs live, not just read code):
55
+ --env KEY=VALUE Env var for the fix run, e.g. AWS_PROFILE=app-ro (repeatable)
56
+ --setup "<cmd>" Command run in the worktree before Claude, e.g. "npm ci" (repeatable)
57
+ --bg "<cmd>" Long-lived command (a dev server) Claude can hit, e.g. "npm run dev" (repeatable)
58
+ --env-file <path> A .env copied into the worktree before setup
59
+ --setup-timeout <min> Timeout for all setup commands (default: job timeout)
60
+ --no-auto-install Skip auto npm/pnpm/yarn install (default: auto-install deps when no --setup given)
61
+
62
+ Examples:
63
+ firefunc-runner connect ffr_xxx --repos-dir ~/code
64
+ firefunc-runner connect ffr_xxx --repo acme/api=/Users/me/code/api
65
+ firefunc-runner connect ffr_xxx --env AWS_PROFILE=app-ro --setup "npm ci" --bg "npm run dev"
66
+ firefunc-runner start
67
+ `;
68
+ function parseFlags(args) {
69
+ const out = {};
70
+ for (let i = 0; i < args.length; i++) {
71
+ const a = args[i];
72
+ if (a?.startsWith('--')) {
73
+ const key = a.slice(2);
74
+ const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : 'true';
75
+ (out[key] ??= []).push(val);
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ async function main() {
81
+ const [cmd, ...rest] = process.argv.slice(2);
82
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
83
+ process.stdout.write(HELP);
84
+ return;
85
+ }
86
+ if (cmd === 'connect') {
87
+ const token = rest.find((a) => !a.startsWith('--'));
88
+ if (!token || !token.startsWith('ffr_')) {
89
+ process.stderr.write('Pass your runner token: firefunc-runner connect ffr_…\n');
90
+ process.exitCode = 1;
91
+ return;
92
+ }
93
+ const f = parseFlags(rest);
94
+ const apiUrl = f.api?.[0]?.trim();
95
+ if (!apiUrl || !/^https?:\/\//.test(apiUrl)) {
96
+ process.stderr.write('Missing --api.\n' +
97
+ ' firefunc-runner connect <token> --api https://your-firefunc-host\n\n' +
98
+ 'Copy the whole command from the Engines screen in FireFunc — it fills\n' +
99
+ 'in both the token and the URL for your workspace.\n');
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ const repos = {};
104
+ for (const r of f.repo ?? []) {
105
+ const [k, v] = r.split('=');
106
+ if (k && v)
107
+ repos[k] = v;
108
+ }
109
+ const claudeEnv = {};
110
+ for (const e of f.env ?? []) {
111
+ const i = e.indexOf('=');
112
+ if (i > 0)
113
+ claudeEnv[e.slice(0, i)] = e.slice(i + 1);
114
+ }
115
+ const cfg = {
116
+ apiUrl,
117
+ token,
118
+ name: f.name?.[0],
119
+ reposDir: f['repos-dir']?.[0],
120
+ repos: Object.keys(repos).length ? repos : undefined,
121
+ claudeBin: f['claude-bin']?.[0],
122
+ quiet: f.quiet ? true : undefined,
123
+ viewerPort: f['viewer-port']?.[0] ? Number(f['viewer-port'][0]) : undefined,
124
+ viewerEnabled: f['no-viewer'] ? false : undefined,
125
+ maxParallel: f['max-parallel']?.[0] ? Number(f['max-parallel'][0]) : undefined,
126
+ devServerBasePort: f['dev-server-base-port']?.[0]
127
+ ? Number(f['dev-server-base-port'][0])
128
+ : undefined,
129
+ claudeCodeOAuthToken: f['claude-oauth-token']?.[0],
130
+ claudeUseApiKey: f['use-api-key'] ? true : undefined,
131
+ engine: f.engine?.[0],
132
+ codexBin: f['codex-bin']?.[0],
133
+ codexUseApiKey: f['codex-use-api-key'] ? true : undefined,
134
+ geminiBin: f['gemini-bin']?.[0],
135
+ geminiApiKey: f['gemini-api-key']?.[0],
136
+ cursorAgentBin: f['cursor-bin']?.[0],
137
+ cursorApiKey: f['cursor-api-key']?.[0],
138
+ claudeEnv: Object.keys(claudeEnv).length ? claudeEnv : undefined,
139
+ setupCommands: f.setup,
140
+ autoInstall: f['no-auto-install'] ? false : undefined,
141
+ setupTimeoutMinutes: f['setup-timeout']?.[0] ? Number(f['setup-timeout'][0]) : undefined,
142
+ envFile: f['env-file']?.[0],
143
+ backgroundCommands: f.bg,
144
+ };
145
+ saveConfig(cfg);
146
+ process.stdout.write(`Saved to ${configPath()}.\nRun "firefunc-runner start" to go online.\n`);
147
+ return;
148
+ }
149
+ if (cmd === 'repo') {
150
+ const cfg = loadConfig();
151
+ if (!cfg) {
152
+ process.stderr.write('Not connected. Run "firefunc-runner connect <token>" first.\n');
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ const positional = rest.filter((a) => !a.startsWith('--'));
157
+ const name = positional[0];
158
+ const localPath = positional.slice(1).join(' ').trim();
159
+ if (!name || !name.includes('/') || !localPath) {
160
+ process.stderr.write('Usage: firefunc-runner repo <owner/repo> <local-path>\n' +
161
+ 'Example: firefunc-runner repo acme/checkout-api "C:\\Users\\me\\checkout-api"\n');
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+ cfg.repos = { ...(cfg.repos ?? {}), [name]: localPath };
166
+ saveConfig(cfg);
167
+ process.stdout.write(`Mapped ${name} → ${localPath}\nRestart the runner to apply: firefunc-runner start\n`);
168
+ return;
169
+ }
170
+ if (cmd === 'brief') {
171
+ const cfg = loadConfig();
172
+ if (!cfg) {
173
+ process.stderr.write('Not connected. Run "firefunc-runner connect <token>" first.\n');
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+ const repo = rest.find((a) => !a.startsWith('--'));
178
+ if (!repo || !repo.includes('/')) {
179
+ process.stderr.write('Usage: firefunc-runner brief <owner/repo>\n' +
180
+ 'Example: firefunc-runner brief acme/checkout-api\n');
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ await runBrief(repo, cfg);
185
+ return;
186
+ }
187
+ if (cmd === 'status') {
188
+ const cfg = loadConfig();
189
+ if (!cfg) {
190
+ process.stdout.write('Not connected. Run "firefunc-runner connect <token>".\n');
191
+ return;
192
+ }
193
+ process.stdout.write(`Connected to ${cfg.apiUrl}\n` +
194
+ ` name: ${cfg.name ?? '(hostname)'}\n` +
195
+ ` reposDir: ${cfg.reposDir ?? '(none)'}\n` +
196
+ ` repos: ${cfg.repos ? Object.keys(cfg.repos).join(', ') : '(auto by name)'}\n` +
197
+ ` engine: ${cfg.engine ?? 'claude_code'} (default; per-project setting wins)\n` +
198
+ ` claudeBin: ${cfg.claudeBin ?? 'claude'}\n` +
199
+ ` codex: ${cfg.codexBin ?? 'codex'}${cfg.codexUseApiKey ? ' (API billing)' : ' (ChatGPT-plan login)'}\n` +
200
+ ` gemini: ${cfg.geminiBin ?? 'gemini'}${cfg.geminiApiKey ? ' (API key set)' : ' (env auth)'}\n` +
201
+ ` cursor: ${cfg.cursorAgentBin ?? 'cursor-agent'}${cfg.cursorApiKey ? ' (API key set)' : ' (no key — set --cursor-api-key)'}\n` +
202
+ ` liveViewer:${cfg.viewerEnabled === false ? ' off' : ` http://127.0.0.1:${cfg.viewerPort ?? 8787} (local only)`}\n` +
203
+ ` maxParallel:${cfg.maxParallel && cfg.maxParallel > 1 ? ` ${cfg.maxParallel} sessions at once` : ' 1 (serial)'}\n` +
204
+ ` claudeAuth:${cfg.claudeUseApiKey
205
+ ? ' ANTHROPIC_API_KEY (API billing — opted in)'
206
+ : cfg.claudeCodeOAuthToken
207
+ ? ' subscription token'
208
+ : ' your machine Claude login (subscription; API key hidden)'}\n` +
209
+ ` env: ${cfg.claudeEnv ? Object.keys(cfg.claudeEnv).join(', ') : '(none)'}\n` +
210
+ ` setup: ${cfg.setupCommands?.length ? cfg.setupCommands.join(' ; ') : '(none)'}\n` +
211
+ ` autoInstall:${cfg.autoInstall === false ? ' off' : ' on (when no setup)'}\n` +
212
+ ` background:${cfg.backgroundCommands?.length ? ' ' + cfg.backgroundCommands.join(' ; ') : ' (none)'}\n` +
213
+ ` envFile: ${cfg.envFile ?? '(none)'}\n`);
214
+ return;
215
+ }
216
+ if (cmd === 'start') {
217
+ const cfg = loadConfig();
218
+ if (!cfg) {
219
+ process.stderr.write('Not connected. Run "firefunc-runner connect <token>" first.\n');
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+ await startDaemon(cfg);
224
+ return;
225
+ }
226
+ process.stderr.write(`Unknown command "${cmd}".\n\n${HELP}`);
227
+ process.exitCode = 1;
228
+ }
229
+ void main();
230
+ //# sourceMappingURL=cli.js.map