@ahmedalbarghouti/farq 0.0.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +74 -0
  5. package/dist/extract-json.d.ts +5 -0
  6. package/dist/extract-json.js +60 -0
  7. package/dist/git.d.ts +36 -0
  8. package/dist/git.js +277 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +204 -0
  11. package/dist/open-pr.d.ts +26 -0
  12. package/dist/open-pr.js +199 -0
  13. package/dist/providers/claude.d.ts +3 -0
  14. package/dist/providers/claude.js +88 -0
  15. package/dist/providers/fake.d.ts +8 -0
  16. package/dist/providers/fake.js +43 -0
  17. package/dist/providers/index.d.ts +22 -0
  18. package/dist/providers/index.js +40 -0
  19. package/dist/providers/opencode.d.ts +3 -0
  20. package/dist/providers/opencode.js +33 -0
  21. package/dist/render/json.d.ts +2 -0
  22. package/dist/render/json.js +3 -0
  23. package/dist/render/pr.d.ts +7 -0
  24. package/dist/render/pr.js +48 -0
  25. package/dist/render/slack.d.ts +2 -0
  26. package/dist/render/slack.js +25 -0
  27. package/dist/schema.d.ts +74 -0
  28. package/dist/schema.js +39 -0
  29. package/dist/summarize.d.ts +17 -0
  30. package/dist/summarize.js +72 -0
  31. package/dist/template.d.ts +11 -0
  32. package/dist/template.js +87 -0
  33. package/dist/title.d.ts +12 -0
  34. package/dist/title.js +37 -0
  35. package/dist/visual/chrome.d.ts +12 -0
  36. package/dist/visual/chrome.js +62 -0
  37. package/dist/visual/compose.d.ts +16 -0
  38. package/dist/visual/compose.js +58 -0
  39. package/dist/visual/diagram.d.ts +17 -0
  40. package/dist/visual/diagram.js +36 -0
  41. package/dist/visual/gate.d.ts +11 -0
  42. package/dist/visual/gate.js +28 -0
  43. package/dist/visual/mockup.d.ts +26 -0
  44. package/dist/visual/mockup.js +46 -0
  45. package/dist/visual/pipeline.d.ts +23 -0
  46. package/dist/visual/pipeline.js +126 -0
  47. package/package.json +53 -0
package/dist/index.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { relative, resolve } from "node:path";
4
+ import { loadConfig, mergeConfig } from "./config.js";
5
+ import { gatherDiff, NoChangesError, GitError } from "./git.js";
6
+ import { resolveProvider } from "./providers/index.js";
7
+ import { summarize, SummarizeError } from "./summarize.js";
8
+ import { inferTitleConvention } from "./title.js";
9
+ import { fetchRecentPrTitles, openPullRequest } from "./open-pr.js";
10
+ import { runVisualPipeline } from "./visual/pipeline.js";
11
+ import { ChromeError } from "./visual/chrome.js";
12
+ import { renderPr } from "./render/pr.js";
13
+ import { renderSlack } from "./render/slack.js";
14
+ import { renderJson } from "./render/json.js";
15
+ async function run(type, opts) {
16
+ const cwd = process.cwd();
17
+ const fileConfig = loadConfig({ cwd });
18
+ const flagConfig = {
19
+ provider: opts.provider,
20
+ tone: opts.tone,
21
+ models: opts.modelCheap
22
+ ? {
23
+ claudeCheap: opts.modelCheap,
24
+ opencodeCheap: opts.modelCheap,
25
+ }
26
+ : undefined,
27
+ };
28
+ const config = mergeConfig(fileConfig, flagConfig);
29
+ const log = (msg) => {
30
+ console.error(msg);
31
+ };
32
+ let provider;
33
+ try {
34
+ provider = await resolveProvider({
35
+ flag: config.provider,
36
+ config,
37
+ log,
38
+ });
39
+ }
40
+ catch (err) {
41
+ log(err instanceof Error ? err.message : String(err));
42
+ return 1;
43
+ }
44
+ const tone = config.tone ?? "technical";
45
+ const imagesEnabled = type === "pr" ? !opts.noImages : false;
46
+ let diff;
47
+ try {
48
+ log("farq: gathering diff…");
49
+ diff = await gatherDiff({ cwd, range: opts.range });
50
+ }
51
+ catch (err) {
52
+ if (err instanceof NoChangesError || err instanceof GitError) {
53
+ log(err.message);
54
+ return 1;
55
+ }
56
+ log(err instanceof Error ? err.message : String(err));
57
+ return 1;
58
+ }
59
+ let titleBlurb = "";
60
+ if (type === "pr") {
61
+ try {
62
+ const titles = await fetchRecentPrTitles(cwd);
63
+ titleBlurb = inferTitleConvention(titles).blurb;
64
+ }
65
+ catch {
66
+ // optional
67
+ }
68
+ }
69
+ log(`farq: summarizing with ${provider.name}…`);
70
+ let summary;
71
+ try {
72
+ summary = await summarize({
73
+ provider,
74
+ diff,
75
+ tone,
76
+ titleConventionBlurb: titleBlurb || undefined,
77
+ });
78
+ }
79
+ catch (err) {
80
+ if (err instanceof SummarizeError) {
81
+ log(err.message);
82
+ return 2;
83
+ }
84
+ log(err instanceof Error ? err.message : String(err));
85
+ return 2;
86
+ }
87
+ const cheapModel = provider.name === "claude"
88
+ ? config.models?.claudeCheap
89
+ : provider.name === "opencode"
90
+ ? config.models?.opencodeCheap ?? process.env.FARQ_OPENCODE_MODEL
91
+ : undefined;
92
+ let imagePath = null;
93
+ let images = [];
94
+ if (imagesEnabled || (opts.before && opts.after)) {
95
+ log("farq: visual pipeline…");
96
+ try {
97
+ const visual = await runVisualPipeline({
98
+ cwd,
99
+ outDir: opts.out ?? ".farq",
100
+ summary,
101
+ diff,
102
+ provider,
103
+ modelCheap: cheapModel,
104
+ noImages: opts.noImages && !(opts.before && opts.after),
105
+ before: opts.before,
106
+ after: opts.after,
107
+ verbose: opts.verbose,
108
+ log: opts.verbose ? log : () => undefined,
109
+ });
110
+ imagePath = visual.imagePath;
111
+ images = visual.images;
112
+ if (visual.warning)
113
+ log(`farq: ${visual.warning}`);
114
+ }
115
+ catch (err) {
116
+ if (err instanceof ChromeError && opts.before && opts.after) {
117
+ log(err.message);
118
+ return 1;
119
+ }
120
+ log(`farq: ${err instanceof Error ? err.message : String(err)} (continuing without image)`);
121
+ }
122
+ }
123
+ const relImage = imagePath != null ? relative(cwd, resolve(imagePath)).replaceAll("\\", "/") : null;
124
+ let artifact = "";
125
+ if (type === "pr") {
126
+ artifact = renderPr({ summary, imagePath: relImage });
127
+ }
128
+ else if (type === "slack") {
129
+ artifact = renderSlack(summary);
130
+ }
131
+ else {
132
+ artifact = renderJson(summary, images.map((p) => relative(cwd, p).replaceAll("\\", "/")));
133
+ }
134
+ if (type === "pr" && opts.open) {
135
+ log("farq: opening PR…");
136
+ try {
137
+ const result = await openPullRequest({
138
+ cwd,
139
+ summary,
140
+ bodyMarkdown: artifact.includes("\n\n")
141
+ ? artifact.split("\n\n").slice(1).join("\n\n")
142
+ : artifact,
143
+ imagePath,
144
+ });
145
+ if (result.skipped) {
146
+ log(`farq: ${result.reason}`);
147
+ }
148
+ else {
149
+ log(`farq: PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
150
+ if (result.warning)
151
+ log(`farq: ${result.warning}`);
152
+ }
153
+ }
154
+ catch (err) {
155
+ log(err instanceof Error ? err.message : String(err));
156
+ return 1;
157
+ }
158
+ }
159
+ process.stdout.write(artifact);
160
+ return 0;
161
+ }
162
+ function addShared(cmd) {
163
+ return cmd
164
+ .option("-r, --range <range>", "git range, e.g. main..feature")
165
+ .option("-p, --provider <name>", "claude | opencode | fake")
166
+ .option("-t, --tone <tone>", "technical | client", "technical")
167
+ .option("--before <path>", "manual before screenshot")
168
+ .option("--after <path>", "manual after screenshot")
169
+ .option("--no-images", "skip image generation/composition")
170
+ .option("-o, --out <dir>", "output dir for images", ".farq")
171
+ .option("--model-cheap <id>", "model for visual generation")
172
+ .option("-v, --verbose", "verbose logging", false);
173
+ }
174
+ async function main() {
175
+ const program = new Command();
176
+ program
177
+ .name("farq")
178
+ .description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
179
+ .version("0.0.1");
180
+ addShared(program
181
+ .command("pr", { isDefault: true })
182
+ .description("PR title + body markdown")
183
+ .option("--open", "create a GitHub PR with gh (template-aware)", false)
184
+ .action(async (opts) => {
185
+ process.exitCode = await run("pr", opts);
186
+ }));
187
+ addShared(program
188
+ .command("slack")
189
+ .description("Slack mrkdwn daily update")
190
+ .action(async (opts) => {
191
+ process.exitCode = await run("slack", { ...opts, noImages: true });
192
+ }));
193
+ addShared(program
194
+ .command("json")
195
+ .description("Structured ChangeSummary JSON")
196
+ .action(async (opts) => {
197
+ process.exitCode = await run("json", { ...opts, noImages: true });
198
+ }));
199
+ await program.parseAsync(process.argv);
200
+ }
201
+ main().catch((err) => {
202
+ console.error(err instanceof Error ? err.message : String(err));
203
+ process.exitCode = 1;
204
+ });
@@ -0,0 +1,26 @@
1
+ import type { ChangeSummary } from "./schema.js";
2
+ export type OpenPrResult = {
3
+ skipped: true;
4
+ reason: string;
5
+ } | {
6
+ skipped: false;
7
+ action: "created" | "updated";
8
+ url?: string;
9
+ warning?: string;
10
+ };
11
+ export declare function resolveDefaultBranch(cwd: string): Promise<string>;
12
+ export declare function currentBranch(cwd: string): Promise<string>;
13
+ export declare function fetchRecentPrTitles(cwd: string, limit?: number): Promise<string[]>;
14
+ /** Existing open PR for the current branch, if any. */
15
+ export declare function findExistingPr(cwd: string): Promise<{
16
+ number: number;
17
+ url: string;
18
+ } | null>;
19
+ export declare function openPullRequest(options: {
20
+ cwd: string;
21
+ summary: ChangeSummary;
22
+ bodyMarkdown: string;
23
+ imagePath?: string | null;
24
+ }): Promise<OpenPrResult>;
25
+ /** Best-effort: prerelease asset URL for the PNG. */
26
+ export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
@@ -0,0 +1,199 @@
1
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { execa } from "execa";
5
+ import { fillPrTemplate, findPrTemplate } from "./template.js";
6
+ const GH_TIMEOUT = 60_000;
7
+ export async function resolveDefaultBranch(cwd) {
8
+ try {
9
+ const { stdout } = await execa("gh", [
10
+ "repo",
11
+ "view",
12
+ "--json",
13
+ "defaultBranchRef",
14
+ "--jq",
15
+ ".defaultBranchRef.name",
16
+ ], { cwd, timeout: GH_TIMEOUT, reject: true });
17
+ if (stdout.trim())
18
+ return stdout.trim();
19
+ }
20
+ catch {
21
+ // fall through
22
+ }
23
+ for (const name of ["main", "master"]) {
24
+ try {
25
+ await execa("git", ["rev-parse", "--verify", name], {
26
+ cwd,
27
+ timeout: 10_000,
28
+ reject: true,
29
+ });
30
+ return name;
31
+ }
32
+ catch {
33
+ // try next
34
+ }
35
+ }
36
+ return "main";
37
+ }
38
+ export async function currentBranch(cwd) {
39
+ const { stdout } = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
40
+ cwd,
41
+ timeout: 10_000,
42
+ reject: true,
43
+ });
44
+ return stdout.trim();
45
+ }
46
+ export async function fetchRecentPrTitles(cwd, limit = 20) {
47
+ try {
48
+ const { stdout } = await execa("gh", [
49
+ "pr",
50
+ "list",
51
+ "--state",
52
+ "merged",
53
+ "--limit",
54
+ String(limit),
55
+ "--json",
56
+ "title",
57
+ "--jq",
58
+ ".[].title",
59
+ ], { cwd, timeout: GH_TIMEOUT, reject: true });
60
+ return stdout
61
+ .split("\n")
62
+ .map((s) => s.trim())
63
+ .filter(Boolean);
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ /** Existing open PR for the current branch, if any. */
70
+ export async function findExistingPr(cwd) {
71
+ try {
72
+ const { stdout, exitCode } = await execa("gh", ["pr", "view", "--json", "number,url"], { cwd, timeout: GH_TIMEOUT, reject: false });
73
+ if (exitCode !== 0 || !stdout.trim())
74
+ return null;
75
+ const parsed = JSON.parse(stdout);
76
+ if (typeof parsed.number === "number" && typeof parsed.url === "string") {
77
+ return { number: parsed.number, url: parsed.url };
78
+ }
79
+ return null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ export async function openPullRequest(options) {
86
+ const cwd = options.cwd;
87
+ const branch = await currentBranch(cwd);
88
+ const defaultBranch = await resolveDefaultBranch(cwd);
89
+ if (branch === defaultBranch) {
90
+ return {
91
+ skipped: true,
92
+ reason: `Already on ${defaultBranch} — skipping PR create`,
93
+ };
94
+ }
95
+ let imageUrl = null;
96
+ let warning;
97
+ if (options.imagePath) {
98
+ try {
99
+ imageUrl = await uploadPrImage(cwd, options.imagePath, branch);
100
+ }
101
+ catch (err) {
102
+ warning = `Image upload failed — attach manually: ${options.imagePath} (${err instanceof Error ? err.message : String(err)})`;
103
+ }
104
+ }
105
+ const template = findPrTemplate(cwd);
106
+ const { title, body } = fillPrTemplate({
107
+ template,
108
+ summary: options.summary,
109
+ bodyMarkdown: options.bodyMarkdown,
110
+ imageUrl,
111
+ });
112
+ const dir = mkdtempSync(join(tmpdir(), "farq-pr-"));
113
+ const bodyFile = join(dir, "body.md");
114
+ writeFileSync(bodyFile, body, "utf8");
115
+ try {
116
+ const existing = await findExistingPr(cwd);
117
+ if (existing) {
118
+ await execa("gh", [
119
+ "pr",
120
+ "edit",
121
+ String(existing.number),
122
+ "--title",
123
+ title,
124
+ "--body-file",
125
+ bodyFile,
126
+ ], { cwd, timeout: GH_TIMEOUT, reject: true });
127
+ try {
128
+ await execa("gh", ["pr", "view", "--web"], {
129
+ cwd,
130
+ timeout: GH_TIMEOUT,
131
+ reject: false,
132
+ });
133
+ }
134
+ catch {
135
+ // non-fatal
136
+ }
137
+ return {
138
+ skipped: false,
139
+ action: "updated",
140
+ url: existing.url,
141
+ warning,
142
+ };
143
+ }
144
+ const created = await execa("gh", ["pr", "create", "--title", title, "--body-file", bodyFile], { cwd, timeout: GH_TIMEOUT, reject: true });
145
+ try {
146
+ await execa("gh", ["pr", "view", "--web"], {
147
+ cwd,
148
+ timeout: GH_TIMEOUT,
149
+ reject: false,
150
+ });
151
+ }
152
+ catch {
153
+ // non-fatal
154
+ }
155
+ return {
156
+ skipped: false,
157
+ action: "created",
158
+ url: created.stdout?.trim() || undefined,
159
+ warning,
160
+ };
161
+ }
162
+ catch (err) {
163
+ const msg = err instanceof Error ? err.message : String(err);
164
+ throw new Error(`gh pr create/edit failed: ${msg}`);
165
+ }
166
+ finally {
167
+ rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ }
170
+ /** Best-effort: prerelease asset URL for the PNG. */
171
+ export async function uploadPrImage(cwd, imagePath, branch) {
172
+ const tag = `farq-assets-${branch.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}`;
173
+ try {
174
+ await execa("gh", ["release", "delete", tag, "--yes", "--cleanup-tag"], {
175
+ cwd,
176
+ timeout: GH_TIMEOUT,
177
+ reject: false,
178
+ });
179
+ }
180
+ catch {
181
+ // ok if missing
182
+ }
183
+ await execa("gh", [
184
+ "release",
185
+ "create",
186
+ tag,
187
+ imagePath,
188
+ "--title",
189
+ `farq assets (${branch})`,
190
+ "--notes",
191
+ "Auto-uploaded by farq for PR description embedding.",
192
+ "--prerelease",
193
+ ], { cwd, timeout: GH_TIMEOUT, reject: true });
194
+ const { stdout } = await execa("gh", ["release", "view", tag, "--json", "assets", "--jq", ".assets[0].url"], { cwd, timeout: GH_TIMEOUT, reject: true });
195
+ const url = stdout.trim();
196
+ if (!url)
197
+ throw new Error("no asset URL returned");
198
+ return url;
199
+ }
@@ -0,0 +1,3 @@
1
+ import type { CompleteOptions } from "./fake.js";
2
+ export declare function complete(prompt: string, options?: CompleteOptions): Promise<string>;
3
+ export declare function isInstalled(): Promise<boolean>;
@@ -0,0 +1,88 @@
1
+ import { execa } from "execa";
2
+ const TIMEOUT_MS = 120_000;
3
+ export async function complete(prompt, options = {}) {
4
+ const baseArgs = ["-p", "--output-format", "json", "--max-turns", "1"];
5
+ if (options.model)
6
+ baseArgs.push("--model", options.model);
7
+ // Prefer without allowedTools first — empty allowedTools hangs/fails on some versions.
8
+ const attempts = [[...baseArgs], [...baseArgs, "--allowedTools", ""]];
9
+ let lastErr;
10
+ for (const args of attempts) {
11
+ try {
12
+ const result = await execa("claude", args, {
13
+ input: prompt,
14
+ timeout: TIMEOUT_MS,
15
+ reject: false,
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+ if (result.timedOut) {
20
+ throw new Error("claude did not respond — check that it's authenticated (`claude` login)");
21
+ }
22
+ const text = (result.stdout || result.stderr || "").trim();
23
+ if (!text && result.exitCode !== 0) {
24
+ throw new Error(`claude failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim()}`);
25
+ }
26
+ try {
27
+ return parseClaudeOutput(text);
28
+ }
29
+ catch (parseErr) {
30
+ // Retry next arg set only when allowedTools looks unsupported
31
+ if (args.includes("--allowedTools") &&
32
+ /allowedTools|unknown option|unexpected/i.test(`${result.stderr ?? ""} ${text}`)) {
33
+ lastErr = parseErr;
34
+ continue;
35
+ }
36
+ throw parseErr;
37
+ }
38
+ }
39
+ catch (err) {
40
+ const e = err;
41
+ if (e.timedOut || /did not respond/i.test(e.message ?? "")) {
42
+ throw new Error("claude did not respond — check that it's authenticated (`claude` login)");
43
+ }
44
+ lastErr = err;
45
+ if (!args.includes("--allowedTools")) {
46
+ // try with allowedTools only if first attempt was a flag-related failure
47
+ const msg = e.message ?? "";
48
+ if (/unknown option|allowedTools/i.test(msg))
49
+ continue;
50
+ throw err instanceof Error ? err : new Error(String(err));
51
+ }
52
+ }
53
+ }
54
+ throw lastErr instanceof Error ? lastErr : new Error("claude failed");
55
+ }
56
+ function parseClaudeOutput(stdout) {
57
+ try {
58
+ const envelope = JSON.parse(stdout);
59
+ if (envelope.is_error) {
60
+ const detail = envelope.result || envelope.error || "claude returned is_error: true";
61
+ if (/authenticat|401|OAuth/i.test(detail)) {
62
+ throw new Error(`claude authentication failed — run \`claude\` and sign in again. (${detail})`);
63
+ }
64
+ throw new Error(detail);
65
+ }
66
+ if (typeof envelope.result === "string")
67
+ return envelope.result;
68
+ }
69
+ catch (err) {
70
+ if (err instanceof Error && /claude |authenticat|is_error/i.test(err.message)) {
71
+ throw err;
72
+ }
73
+ // fall through — maybe raw text / not JSON
74
+ }
75
+ if (!stdout.trim()) {
76
+ throw new Error("claude returned empty output");
77
+ }
78
+ return stdout;
79
+ }
80
+ export async function isInstalled() {
81
+ try {
82
+ await execa("claude", ["--version"], { timeout: 10_000, reject: true });
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
@@ -0,0 +1,8 @@
1
+ import type { ChangeSummary } from "../schema.js";
2
+ export declare const FAKE_SUMMARY: ChangeSummary;
3
+ export declare const FAKE_BEFORE_HTML = "<!doctype html><html><body style=\"font-family:system-ui;padding:24px;background:#f6f6f6\">\n<main style=\"max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd\">\n <h1 style=\"font-size:18px;margin:0 0 8px\">Order #1042</h1>\n <p style=\"margin:0;color:#444\">Total: $48.00</p>\n</main></body></html>";
4
+ export declare const FAKE_AFTER_HTML = "<!doctype html><html><body style=\"font-family:system-ui;padding:24px;background:#f6f6f6\">\n<main style=\"max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd\">\n <h1 style=\"font-size:18px;margin:0 0 8px\">Order #1042</h1>\n <p style=\"margin:0 0 8px;color:#444\">Total: $48.00</p>\n <p style=\"margin:0;color:#0a7\">Refund status: pending</p>\n</main></body></html>";
5
+ export type CompleteOptions = {
6
+ model?: string;
7
+ };
8
+ export declare function complete(prompt: string, _options?: CompleteOptions): Promise<string>;
@@ -0,0 +1,43 @@
1
+ export const FAKE_SUMMARY = {
2
+ headline: "Add refund status to order responses",
3
+ overview: "Orders now include a refund_status field so clients can track refund progress without extra lookups.",
4
+ items: [
5
+ {
6
+ category: "feature",
7
+ title: "Refund status on orders",
8
+ description: "Adds refund_status to the order API response shape.",
9
+ why_it_matters: "Support and customers can see whether a refund is pending or complete.",
10
+ files: ["src/orders/serializer.ts", "src/orders/types.ts"],
11
+ },
12
+ {
13
+ category: "docs",
14
+ title: "Document refund_status",
15
+ description: "Updates OpenAPI notes for the new field.",
16
+ files: ["docs/api.md"],
17
+ },
18
+ ],
19
+ breaking_changes: [],
20
+ visual_notes: "Show a simple status badge on the order card.",
21
+ };
22
+ export const FAKE_BEFORE_HTML = `<!doctype html><html><body style="font-family:system-ui;padding:24px;background:#f6f6f6">
23
+ <main style="max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd">
24
+ <h1 style="font-size:18px;margin:0 0 8px">Order #1042</h1>
25
+ <p style="margin:0;color:#444">Total: $48.00</p>
26
+ </main></body></html>`;
27
+ export const FAKE_AFTER_HTML = `<!doctype html><html><body style="font-family:system-ui;padding:24px;background:#f6f6f6">
28
+ <main style="max-width:420px;margin:auto;background:#fff;padding:16px;border:1px solid #ddd">
29
+ <h1 style="font-size:18px;margin:0 0 8px">Order #1042</h1>
30
+ <p style="margin:0 0 8px;color:#444">Total: $48.00</p>
31
+ <p style="margin:0;color:#0a7">Refund status: pending</p>
32
+ </main></body></html>`;
33
+ export async function complete(prompt, _options = {}) {
34
+ if (prompt.includes("before_html") || prompt.includes("feasible")) {
35
+ return JSON.stringify({
36
+ feasible: true,
37
+ before_html: FAKE_BEFORE_HTML,
38
+ after_html: FAKE_AFTER_HTML,
39
+ viewport: { width: 900, height: 700 },
40
+ });
41
+ }
42
+ return JSON.stringify(FAKE_SUMMARY);
43
+ }
@@ -0,0 +1,22 @@
1
+ import type { FarqConfig, ProviderName } from "../config.js";
2
+ import type { CompleteOptions } from "./fake.js";
3
+ export type Provider = {
4
+ name: ProviderName;
5
+ complete: (prompt: string, options?: CompleteOptions) => Promise<string>;
6
+ };
7
+ export type ResolveProviderOptions = {
8
+ flag?: ProviderName;
9
+ config?: FarqConfig;
10
+ detect?: () => Promise<{
11
+ claude: boolean;
12
+ opencode: boolean;
13
+ }>;
14
+ log?: (msg: string) => void;
15
+ };
16
+ export declare function detectProviders(): Promise<{
17
+ claude: boolean;
18
+ opencode: boolean;
19
+ }>;
20
+ export declare function resolveProvider(options?: ResolveProviderOptions): Promise<Provider>;
21
+ export declare function getProvider(name: ProviderName): Provider;
22
+ export { FAKE_SUMMARY, FAKE_BEFORE_HTML, FAKE_AFTER_HTML } from "./fake.js";
@@ -0,0 +1,40 @@
1
+ import * as claude from "./claude.js";
2
+ import * as opencode from "./opencode.js";
3
+ import * as fake from "./fake.js";
4
+ export async function detectProviders() {
5
+ const [c, o] = await Promise.all([
6
+ claude.isInstalled(),
7
+ opencode.isInstalled(),
8
+ ]);
9
+ return { claude: c, opencode: o };
10
+ }
11
+ export async function resolveProvider(options = {}) {
12
+ const flag = options.flag;
13
+ if (flag)
14
+ return getProvider(flag);
15
+ if (options.config?.provider)
16
+ return getProvider(options.config.provider);
17
+ const detected = options.detect
18
+ ? await options.detect()
19
+ : await detectProviders();
20
+ if (detected.claude && detected.opencode) {
21
+ options.log?.("Both claude and opencode found — using claude. Set provider in ~/.config/farq/config.json or pass --provider to override.");
22
+ return getProvider("claude");
23
+ }
24
+ if (detected.claude)
25
+ return getProvider("claude");
26
+ if (detected.opencode)
27
+ return getProvider("opencode");
28
+ throw new Error("No AI provider found. Install Claude Code (https://claude.ai/code) or OpenCode, or pass --provider fake for testing.");
29
+ }
30
+ export function getProvider(name) {
31
+ switch (name) {
32
+ case "claude":
33
+ return { name, complete: claude.complete };
34
+ case "opencode":
35
+ return { name, complete: opencode.complete };
36
+ case "fake":
37
+ return { name, complete: fake.complete };
38
+ }
39
+ }
40
+ export { FAKE_SUMMARY, FAKE_BEFORE_HTML, FAKE_AFTER_HTML } from "./fake.js";
@@ -0,0 +1,3 @@
1
+ import type { CompleteOptions } from "./fake.js";
2
+ export declare function complete(prompt: string, options?: CompleteOptions): Promise<string>;
3
+ export declare function isInstalled(): Promise<boolean>;
@@ -0,0 +1,33 @@
1
+ import { execa } from "execa";
2
+ const TIMEOUT_MS = 120_000;
3
+ export async function complete(prompt, options = {}) {
4
+ const args = ["run"];
5
+ if (options.model)
6
+ args.push("--model", options.model);
7
+ args.push(prompt);
8
+ try {
9
+ const result = await execa("opencode", args, {
10
+ timeout: TIMEOUT_MS,
11
+ reject: true,
12
+ stdout: "pipe",
13
+ stderr: "pipe",
14
+ });
15
+ return result.stdout;
16
+ }
17
+ catch (err) {
18
+ const e = err;
19
+ if (e.timedOut) {
20
+ throw new Error("opencode did not respond — check that it's authenticated");
21
+ }
22
+ throw new Error(`opencode failed: ${(e.stderr || e.message || "unknown error").trim()}`);
23
+ }
24
+ }
25
+ export async function isInstalled() {
26
+ try {
27
+ await execa("opencode", ["--version"], { timeout: 10_000, reject: true });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChangeSummary } from "../schema.js";
2
+ export declare function renderJson(summary: ChangeSummary, images?: string[]): string;