@ahmedalbarghouti/farq 0.0.3 → 0.1.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/README.md +51 -2
- package/dist/concurrency.d.ts +2 -0
- package/dist/concurrency.js +16 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +25 -0
- package/dist/git.d.ts +7 -3
- package/dist/git.js +36 -27
- package/dist/index.js +109 -30
- package/dist/providers/claude.d.ts +1 -0
- package/dist/providers/claude.js +49 -32
- package/dist/providers/fake.d.ts +5 -2
- package/dist/providers/fake.js +26 -15
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/schema.d.ts +40 -7
- package/dist/schema.js +14 -1
- package/dist/summarize.js +7 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/lines.d.ts +19 -1
- package/dist/ui/lines.js +81 -39
- package/dist/ui/logger.d.ts +9 -2
- package/dist/ui/logger.js +15 -4
- package/dist/ui/spinner.d.ts +15 -2
- package/dist/ui/spinner.js +105 -22
- package/dist/ui/theme.d.ts +1 -0
- package/dist/ui/theme.js +3 -0
- package/dist/visual/chrome.js +16 -0
- package/dist/visual/cluster.d.ts +8 -2
- package/dist/visual/cluster.js +36 -12
- package/dist/visual/compose.d.ts +6 -1
- package/dist/visual/compose.js +11 -28
- package/dist/visual/css-scope.d.ts +6 -0
- package/dist/visual/css-scope.js +162 -0
- package/dist/visual/design.d.ts +103 -0
- package/dist/visual/design.js +327 -0
- package/dist/visual/diagram.d.ts +7 -0
- package/dist/visual/diagram.js +36 -18
- package/dist/visual/mockup.d.ts +15 -12
- package/dist/visual/mockup.js +45 -26
- package/dist/visual/pipeline.d.ts +30 -1
- package/dist/visual/pipeline.js +71 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,12 +91,25 @@ Validated change summary plus an `images` array of produced file paths.
|
|
|
91
91
|
|
|
92
92
|
## Images (honesty policy)
|
|
93
93
|
|
|
94
|
-
- Visuals come from the diff only.
|
|
95
|
-
-
|
|
94
|
+
- Visuals come from the diff only. The summarize call also returns the grouping of items into **up to 5** visual topics by intent (same feature → one image; truly unrelated domains → separate), so no extra model round-trip is spent on it. A dedicated grouping call, then file-overlap, are the fallbacks. UI markup gets a mockup attempt; everything else gets a small concept flowchart. If a topic is infeasible, it is skipped (still exit 0).
|
|
95
|
+
- Every visual is **1280×720** and carries a small **generated preview** badge.
|
|
96
96
|
- Diagrams stay conceptual — no code dumps.
|
|
97
97
|
- Missing Chrome / visual failure **soft-degrades** to text-only with a stderr warning (exit 0), unless you passed `--before`/`--after` (then hard-fail).
|
|
98
98
|
- A local image path will not render on GitHub until the file is attached or uploaded. `--open` tries a best-effort upload (and rewrites stdout to the hosted URL).
|
|
99
99
|
|
|
100
|
+
### Consistent visual style
|
|
101
|
+
|
|
102
|
+
farq owns the page frame — background, header, Before/After labels, badge, typography and the colour palette. The model only fills in the two panel interiors, and it must draw every colour, font and radius from a fixed set of CSS custom properties (`--fq-accent`, `--fq-text-muted`, `--fq-surface`, …). The only literals it may use are colours the diff states outright. That is what keeps two runs on two branches looking like the same product.
|
|
103
|
+
|
|
104
|
+
Two palettes ship today, `midnight` (default) and `daylight`:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
farq pr --theme daylight
|
|
108
|
+
farq pr --accent "#ff5722"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Content is measured and scaled to fit the frame after rendering, so a tall mockup shrinks instead of getting clipped, and the model is not asked to guess at pixel budgets.
|
|
112
|
+
|
|
100
113
|
## Config
|
|
101
114
|
|
|
102
115
|
Precedence: **flags → project → global**.
|
|
@@ -111,10 +124,18 @@ Precedence: **flags → project → global**.
|
|
|
111
124
|
"models": {
|
|
112
125
|
"claudeCheap": "haiku",
|
|
113
126
|
"opencodeCheap": "provider/model"
|
|
127
|
+
},
|
|
128
|
+
"visual": {
|
|
129
|
+
"theme": "midnight",
|
|
130
|
+
"accent": "#4dd0e1",
|
|
131
|
+
"maxTopics": 3,
|
|
132
|
+
"concurrency": 3
|
|
114
133
|
}
|
|
115
134
|
}
|
|
116
135
|
```
|
|
117
136
|
|
|
137
|
+
`visual.maxTopics` caps how many images a run may generate (each costs one model call), and `visual.concurrency` is how many are generated at once. `visual.fontImport` can point at a webfont stylesheet; leaving it unset keeps rendering offline and fast.
|
|
138
|
+
|
|
118
139
|
If both `claude` and `opencode` are installed and nothing is configured, farq uses **claude** and prints how to override (no interactive prompt).
|
|
119
140
|
|
|
120
141
|
## Flags
|
|
@@ -128,6 +149,9 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
|
|
|
128
149
|
| `--no-images` | skip visuals |
|
|
129
150
|
| `-o, --out` | image output dir (default: OS user cache, outside the repo) |
|
|
130
151
|
| `--model-cheap` | cheap model id for visuals |
|
|
152
|
+
| `--theme` | visual palette: `midnight` \| `daylight` |
|
|
153
|
+
| `--accent` | override the theme accent colour |
|
|
154
|
+
| `--max-visuals` | cap generated visuals (1–5) |
|
|
131
155
|
| `-v, --verbose` | verbose logs (incl. `feasible: false` reasons) |
|
|
132
156
|
| `--open` | (`pr` only) create PR with `gh` |
|
|
133
157
|
|
|
@@ -141,6 +165,31 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
|
|
|
141
165
|
|
|
142
166
|
Progress → **stderr**. Artifact → **stdout**.
|
|
143
167
|
|
|
168
|
+
## Progress output
|
|
169
|
+
|
|
170
|
+
Each stage is numbered, names what it is doing, and reports how long it took:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
farq ⠹ [2/3] summary · claude is naming things (hard part)… · 11s
|
|
174
|
+
farq ✓ summarized with claude 12.4s
|
|
175
|
+
farq ⠸ [3/3] visuals · claude is sketching the after… · 1/3 · Refund status badge · 8s
|
|
176
|
+
farq ✓ 3 visuals ready 41.2s
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Non-interactive terminals (CI, pipes) get one plain line per stage transition instead of a spinner.
|
|
180
|
+
|
|
181
|
+
## Speed
|
|
182
|
+
|
|
183
|
+
A `farq pr` run makes as few model calls as it can, and overlaps everything that does not depend on the model:
|
|
184
|
+
|
|
185
|
+
- Provider detection, the diff and the `gh` title lookup all start at once.
|
|
186
|
+
- Topic grouping rides along on the summarize response instead of being its own request.
|
|
187
|
+
- File contents for the visuals are read while the summary is still being written.
|
|
188
|
+
- Each visual is a **single** HTML document with one shared stylesheet — roughly half the tokens of emitting two standalone pages, and one Chrome screenshot instead of three.
|
|
189
|
+
- Visuals for different topics are generated in parallel (`visual.concurrency`).
|
|
190
|
+
|
|
191
|
+
If a run still feels slow, the lever with the most travel is `--model-cheap` (for example `haiku`), followed by `--max-visuals 1`.
|
|
192
|
+
|
|
144
193
|
## Repository ops
|
|
145
194
|
|
|
146
195
|
- **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Map over items with a bounded number of in-flight tasks, preserving order. */
|
|
2
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
3
|
+
const max = Math.max(1, Math.floor(limit));
|
|
4
|
+
const results = new Array(items.length);
|
|
5
|
+
let cursor = 0;
|
|
6
|
+
async function worker() {
|
|
7
|
+
while (true) {
|
|
8
|
+
const index = cursor++;
|
|
9
|
+
if (index >= items.length)
|
|
10
|
+
return;
|
|
11
|
+
results[index] = await fn(items[index], index);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
await Promise.all(Array.from({ length: Math.min(max, items.length) }, () => worker()));
|
|
15
|
+
return results;
|
|
16
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
import { type ThemeName } from "./visual/design.js";
|
|
1
2
|
export type ProviderName = "claude" | "opencode" | "fake";
|
|
2
3
|
export type ToneName = "technical" | "client";
|
|
4
|
+
export type VisualConfig = {
|
|
5
|
+
/** Palette used by every generated mockup and diagram. */
|
|
6
|
+
theme?: ThemeName;
|
|
7
|
+
/** Override the single accent color within the chosen theme. */
|
|
8
|
+
accent?: string;
|
|
9
|
+
/** Optional webfont stylesheet; omitted keeps renders offline and fast. */
|
|
10
|
+
fontImport?: string;
|
|
11
|
+
fontSans?: string;
|
|
12
|
+
fontDisplay?: string;
|
|
13
|
+
/** Upper bound on generated visuals (each one costs a model call). */
|
|
14
|
+
maxTopics?: number;
|
|
15
|
+
/** How many visuals to generate at once. */
|
|
16
|
+
concurrency?: number;
|
|
17
|
+
};
|
|
3
18
|
export type FarqConfig = {
|
|
4
19
|
provider?: ProviderName;
|
|
5
20
|
tone?: ToneName;
|
|
@@ -7,6 +22,7 @@ export type FarqConfig = {
|
|
|
7
22
|
claudeCheap?: string;
|
|
8
23
|
opencodeCheap?: string;
|
|
9
24
|
};
|
|
25
|
+
visual?: VisualConfig;
|
|
10
26
|
};
|
|
11
27
|
export type LoadConfigOptions = {
|
|
12
28
|
cwd?: string;
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { isThemeName } from "./visual/design.js";
|
|
4
5
|
export function defaultGlobalDir() {
|
|
5
6
|
return join(homedir(), ".config", "farq");
|
|
6
7
|
}
|
|
@@ -21,12 +22,18 @@ export function mergeConfig(base, override) {
|
|
|
21
22
|
...base.models,
|
|
22
23
|
...stripUndefined(override.models ?? {}),
|
|
23
24
|
};
|
|
25
|
+
const visual = {
|
|
26
|
+
...base.visual,
|
|
27
|
+
...stripUndefined(override.visual ?? {}),
|
|
28
|
+
};
|
|
24
29
|
if (provider !== undefined)
|
|
25
30
|
out.provider = provider;
|
|
26
31
|
if (tone !== undefined)
|
|
27
32
|
out.tone = tone;
|
|
28
33
|
if (Object.keys(models).length > 0)
|
|
29
34
|
out.models = models;
|
|
35
|
+
if (Object.keys(visual).length > 0)
|
|
36
|
+
out.visual = visual;
|
|
30
37
|
return out;
|
|
31
38
|
}
|
|
32
39
|
function stripUndefined(obj) {
|
|
@@ -70,5 +77,23 @@ function sanitize(data) {
|
|
|
70
77
|
if (Object.keys(models).length > 0)
|
|
71
78
|
out.models = models;
|
|
72
79
|
}
|
|
80
|
+
if (data.visual && typeof data.visual === "object") {
|
|
81
|
+
const visual = {};
|
|
82
|
+
if (isThemeName(data.visual.theme))
|
|
83
|
+
visual.theme = data.visual.theme;
|
|
84
|
+
for (const key of ["accent", "fontImport", "fontSans", "fontDisplay"]) {
|
|
85
|
+
const value = data.visual[key];
|
|
86
|
+
if (typeof value === "string" && value.trim())
|
|
87
|
+
visual[key] = value.trim();
|
|
88
|
+
}
|
|
89
|
+
for (const key of ["maxTopics", "concurrency"]) {
|
|
90
|
+
const value = data.visual[key];
|
|
91
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 1) {
|
|
92
|
+
visual[key] = Math.floor(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(visual).length > 0)
|
|
96
|
+
out.visual = visual;
|
|
97
|
+
}
|
|
73
98
|
return out;
|
|
74
99
|
}
|
package/dist/git.d.ts
CHANGED
|
@@ -28,9 +28,13 @@ export type GatherDiffOptions = {
|
|
|
28
28
|
};
|
|
29
29
|
export declare function gatherDiff(options?: GatherDiffOptions): Promise<GatherDiffResult>;
|
|
30
30
|
export declare function getFileAtRef(cwd: string, ref: string, filePath: string): Promise<string | null>;
|
|
31
|
-
|
|
32
|
-
export declare function gatherVisualFileContents(cwd: string, files: DiffFile[], baseRef: string | null, budget?: number): Promise<Array<{
|
|
31
|
+
export type VisualFile = {
|
|
33
32
|
path: string;
|
|
34
33
|
before: string;
|
|
35
34
|
after: string;
|
|
36
|
-
}
|
|
35
|
+
};
|
|
36
|
+
/** Size-capped before/after pairs for visual generation. */
|
|
37
|
+
export declare function gatherVisualFileContents(cwd: string, files: DiffFile[], baseRef: string | null, options?: {
|
|
38
|
+
budget?: number;
|
|
39
|
+
mode?: GatherDiffResult["mode"];
|
|
40
|
+
}): Promise<VisualFile[]>;
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mapWithConcurrency } from "./concurrency.js";
|
|
2
5
|
export const SUMMARY_DIFF_BUDGET = 150_000;
|
|
3
6
|
export const VISUAL_DIFF_BUDGET = 60_000;
|
|
4
7
|
const GIT_TIMEOUT_MS = 30_000;
|
|
@@ -47,26 +50,40 @@ export async function getFileAtRef(cwd, ref, filePath) {
|
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
53
|
+
/** Cap on git subprocesses spawned to build visual context. */
|
|
54
|
+
const MAX_VISUAL_FILES = 40;
|
|
55
|
+
const VISUAL_READ_CONCURRENCY = 8;
|
|
50
56
|
/** Size-capped before/after pairs for visual generation. */
|
|
51
|
-
export async function gatherVisualFileContents(cwd, files, baseRef,
|
|
57
|
+
export async function gatherVisualFileContents(cwd, files, baseRef, options = {}) {
|
|
58
|
+
const budget = options.budget ?? VISUAL_DIFF_BUDGET;
|
|
59
|
+
const worktree = options.mode === "worktree";
|
|
60
|
+
const targets = files.slice(0, MAX_VISUAL_FILES);
|
|
61
|
+
const fetched = await mapWithConcurrency(targets, VISUAL_READ_CONCURRENCY, async (file) => {
|
|
62
|
+
const [before, after] = await Promise.all([
|
|
63
|
+
baseRef ? getFileAtRef(cwd, baseRef, file.path) : Promise.resolve(""),
|
|
64
|
+
// In worktree mode HEAD still holds the *old* content, so read from disk.
|
|
65
|
+
worktree
|
|
66
|
+
? readWorktreeFile(cwd, file.path)
|
|
67
|
+
: getFileAtRef(cwd, "HEAD", file.path),
|
|
68
|
+
]);
|
|
69
|
+
return {
|
|
70
|
+
path: file.path,
|
|
71
|
+
before: before ?? "",
|
|
72
|
+
after: after ?? (await readWorktreeFile(cwd, file.path)) ?? "",
|
|
73
|
+
};
|
|
74
|
+
});
|
|
52
75
|
const out = [];
|
|
53
76
|
let used = 0;
|
|
54
|
-
for (const file of
|
|
77
|
+
for (const file of fetched) {
|
|
55
78
|
if (used >= budget)
|
|
56
79
|
break;
|
|
57
|
-
const
|
|
58
|
-
? ((await getFileAtRef(cwd, baseRef, file.path)) ?? "")
|
|
59
|
-
: "";
|
|
60
|
-
const after = (await getFileAtRef(cwd, "HEAD", file.path)) ??
|
|
61
|
-
(await readWorktreeFile(cwd, file.path)) ??
|
|
62
|
-
"";
|
|
63
|
-
const chunk = before.length + after.length;
|
|
80
|
+
const chunk = file.before.length + file.after.length;
|
|
64
81
|
if (used + chunk > budget && out.length > 0)
|
|
65
82
|
break;
|
|
66
83
|
out.push({
|
|
67
84
|
path: file.path,
|
|
68
|
-
before: before.slice(0, budget - used),
|
|
69
|
-
after: after.slice(0, Math.max(0, budget - used - before.length)),
|
|
85
|
+
before: file.before.slice(0, budget - used),
|
|
86
|
+
after: file.after.slice(0, Math.max(0, budget - used - file.before.length)),
|
|
70
87
|
});
|
|
71
88
|
used += Math.min(chunk, budget - used);
|
|
72
89
|
}
|
|
@@ -74,14 +91,11 @@ export async function gatherVisualFileContents(cwd, files, baseRef, budget = VIS
|
|
|
74
91
|
}
|
|
75
92
|
async function gatherRangeDiff(cwd, range, budget) {
|
|
76
93
|
const [baseRef, headRef] = splitRange(range);
|
|
77
|
-
const nameStatus = await
|
|
78
|
-
"diff",
|
|
79
|
-
"--
|
|
80
|
-
|
|
81
|
-
range,
|
|
94
|
+
const [nameStatus, patch, commits] = await Promise.all([
|
|
95
|
+
git(cwd, ["diff", "--name-status", "--find-renames", range]),
|
|
96
|
+
git(cwd, ["diff", "--find-renames", "--unified=5", range]),
|
|
97
|
+
listCommits(cwd, range),
|
|
82
98
|
]);
|
|
83
|
-
const patch = await git(cwd, ["diff", "--find-renames", "--unified=5", range]);
|
|
84
|
-
const commits = await listCommits(cwd, range);
|
|
85
99
|
const files = parseNameStatus(nameStatus.stdout, patch.stdout);
|
|
86
100
|
const { text, truncated } = capText(patch.stdout, budget);
|
|
87
101
|
return {
|
|
@@ -96,14 +110,11 @@ async function gatherRangeDiff(cwd, range, budget) {
|
|
|
96
110
|
};
|
|
97
111
|
}
|
|
98
112
|
async function gatherWorktreeDiff(cwd, budget) {
|
|
99
|
-
const nameStatus = await
|
|
100
|
-
"diff",
|
|
101
|
-
"HEAD",
|
|
102
|
-
|
|
103
|
-
"--find-renames",
|
|
113
|
+
const [nameStatus, patch, untracked] = await Promise.all([
|
|
114
|
+
git(cwd, ["diff", "HEAD", "--name-status", "--find-renames"]),
|
|
115
|
+
git(cwd, ["diff", "HEAD", "--find-renames", "--unified=5"]),
|
|
116
|
+
listUntracked(cwd),
|
|
104
117
|
]);
|
|
105
|
-
const patch = await git(cwd, ["diff", "HEAD", "--find-renames", "--unified=5"]);
|
|
106
|
-
const untracked = await listUntracked(cwd);
|
|
107
118
|
const files = parseNameStatus(nameStatus.stdout, patch.stdout);
|
|
108
119
|
let diffText = patch.stdout;
|
|
109
120
|
for (const path of untracked) {
|
|
@@ -190,8 +201,6 @@ async function listUntracked(cwd) {
|
|
|
190
201
|
}
|
|
191
202
|
async function readWorktreeFile(cwd, filePath) {
|
|
192
203
|
try {
|
|
193
|
-
const { readFileSync } = await import("node:fs");
|
|
194
|
-
const { join } = await import("node:path");
|
|
195
204
|
return readFileSync(join(cwd, filePath), "utf8");
|
|
196
205
|
}
|
|
197
206
|
catch {
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { loadConfig, mergeConfig, } from "./config.js";
|
|
4
|
-
import { gatherDiff } from "./git.js";
|
|
4
|
+
import { gatherDiff, gatherVisualFileContents } from "./git.js";
|
|
5
5
|
import { resolveProvider } from "./providers/index.js";
|
|
6
6
|
import { summarize } from "./summarize.js";
|
|
7
7
|
import { inferTitleConvention } from "./title.js";
|
|
8
8
|
import { fetchRecentPrTitles, openPullRequest } from "./open-pr.js";
|
|
9
9
|
import { runVisualPipeline } from "./visual/pipeline.js";
|
|
10
10
|
import { ChromeError } from "./visual/chrome.js";
|
|
11
|
+
import { DEFAULT_THEME, THEME_NAMES, isThemeName, resolveTheme, } from "./visual/design.js";
|
|
11
12
|
import { renderPr } from "./render/pr.js";
|
|
12
13
|
import { renderSlack } from "./render/slack.js";
|
|
13
14
|
import { renderJson } from "./render/json.js";
|
|
14
|
-
import { createUi } from "./ui/index.js";
|
|
15
|
+
import { createUi, linesFor } from "./ui/index.js";
|
|
15
16
|
import { defaultOutDir, displayImageRef } from "./paths.js";
|
|
16
17
|
async function run(type, opts) {
|
|
17
18
|
const cwd = process.cwd();
|
|
18
19
|
const ui = createUi();
|
|
19
20
|
const fileConfig = loadConfig({ cwd });
|
|
21
|
+
if (opts.theme && !isThemeName(opts.theme)) {
|
|
22
|
+
ui.note(`unknown theme "${opts.theme}" — using ${DEFAULT_THEME} (options: ${THEME_NAMES.join(", ")})`);
|
|
23
|
+
}
|
|
24
|
+
const maxVisuals = Number(opts.maxVisuals);
|
|
20
25
|
const flagConfig = {
|
|
21
26
|
provider: opts.provider,
|
|
22
27
|
tone: opts.tone,
|
|
@@ -26,44 +31,60 @@ async function run(type, opts) {
|
|
|
26
31
|
opencodeCheap: opts.modelCheap,
|
|
27
32
|
}
|
|
28
33
|
: undefined,
|
|
34
|
+
visual: {
|
|
35
|
+
theme: isThemeName(opts.theme) ? opts.theme : undefined,
|
|
36
|
+
accent: opts.accent,
|
|
37
|
+
maxTopics: Number.isFinite(maxVisuals) && maxVisuals >= 1
|
|
38
|
+
? Math.floor(maxVisuals)
|
|
39
|
+
: undefined,
|
|
40
|
+
},
|
|
29
41
|
};
|
|
30
42
|
const config = mergeConfig(fileConfig, flagConfig);
|
|
31
|
-
let provider;
|
|
32
|
-
try {
|
|
33
|
-
provider = await resolveProvider({
|
|
34
|
-
flag: config.provider,
|
|
35
|
-
config,
|
|
36
|
-
log: (msg) => ui.note(msg),
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
catch (err) {
|
|
40
|
-
ui.error(err instanceof Error ? err.message : String(err));
|
|
41
|
-
return 1;
|
|
42
|
-
}
|
|
43
43
|
const tone = config.tone ?? "technical";
|
|
44
44
|
const noImages = opts.noImages === true || opts.images === false;
|
|
45
|
+
const manualShots = Boolean(opts.before && opts.after);
|
|
45
46
|
const imagesEnabled = type === "pr" ? !noImages : false;
|
|
47
|
+
const willVisual = imagesEnabled || manualShots;
|
|
48
|
+
ui.plan(2 + (willVisual ? 1 : 0) + (type === "pr" && opts.open ? 1 : 0));
|
|
49
|
+
// Provider detection, the diff and the `gh` title lookup are independent —
|
|
50
|
+
// start them together so the first model call is not waiting on subprocesses.
|
|
51
|
+
const providerNotes = [];
|
|
52
|
+
const providerPromise = resolveProvider({
|
|
53
|
+
flag: config.provider,
|
|
54
|
+
config,
|
|
55
|
+
log: (msg) => providerNotes.push(msg),
|
|
56
|
+
}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
57
|
+
const titlesPromise = type === "pr" ? fetchRecentPrTitles(cwd).catch(() => []) : null;
|
|
46
58
|
let diff;
|
|
47
59
|
const diffSpin = ui.stage("diff");
|
|
48
60
|
try {
|
|
49
61
|
diff = await gatherDiff({ cwd, range: opts.range });
|
|
50
|
-
diffSpin.succeed(
|
|
62
|
+
diffSpin.succeed(`diff ready — ${describeDiff(diff.files.length)}`);
|
|
51
63
|
}
|
|
52
64
|
catch (err) {
|
|
53
65
|
const msg = err instanceof Error ? err.message : String(err);
|
|
54
66
|
diffSpin.fail(msg);
|
|
55
67
|
return 1;
|
|
56
68
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
69
|
+
const resolved = await providerPromise;
|
|
70
|
+
for (const note of providerNotes)
|
|
71
|
+
ui.note(note);
|
|
72
|
+
if (!resolved.ok) {
|
|
73
|
+
ui.error(resolved.error instanceof Error
|
|
74
|
+
? resolved.error.message
|
|
75
|
+
: String(resolved.error));
|
|
76
|
+
return 1;
|
|
66
77
|
}
|
|
78
|
+
const provider = resolved.value;
|
|
79
|
+
// Read the before/after file bodies while the model writes the summary.
|
|
80
|
+
const visualFiles = imagesEnabled && !manualShots
|
|
81
|
+
? gatherVisualFileContents(cwd, diff.files, diff.baseRef, {
|
|
82
|
+
mode: diff.mode,
|
|
83
|
+
}).catch(() => [])
|
|
84
|
+
: undefined;
|
|
85
|
+
const titleBlurb = titlesPromise
|
|
86
|
+
? inferTitleConvention(await titlesPromise).blurb
|
|
87
|
+
: "";
|
|
67
88
|
let summary;
|
|
68
89
|
const sumSpin = ui.stage("summarize", provider.name);
|
|
69
90
|
try {
|
|
@@ -88,8 +109,49 @@ async function run(type, opts) {
|
|
|
88
109
|
let imagePath = null;
|
|
89
110
|
let images = [];
|
|
90
111
|
let imageMeta = [];
|
|
91
|
-
if (
|
|
92
|
-
const visSpin = ui.stage("
|
|
112
|
+
if (willVisual) {
|
|
113
|
+
const visSpin = ui.stage("topics", {
|
|
114
|
+
provider: provider.name,
|
|
115
|
+
label: "visuals",
|
|
116
|
+
});
|
|
117
|
+
const track = { total: 0, done: 0, current: "" };
|
|
118
|
+
const refresh = () => {
|
|
119
|
+
const parts = [];
|
|
120
|
+
if (track.total > 1)
|
|
121
|
+
parts.push(`${track.done}/${track.total}`);
|
|
122
|
+
if (track.current)
|
|
123
|
+
parts.push(track.current);
|
|
124
|
+
visSpin.detail(parts.join(" · ") || null);
|
|
125
|
+
};
|
|
126
|
+
const onProgress = (event) => {
|
|
127
|
+
switch (event.kind) {
|
|
128
|
+
case "topics":
|
|
129
|
+
track.total = event.total;
|
|
130
|
+
visSpin.setLines(linesFor("visual", provider.name));
|
|
131
|
+
break;
|
|
132
|
+
// The rotating line already names the activity; the detail names the
|
|
133
|
+
// topic being worked on, plus a counter once there is more than one.
|
|
134
|
+
case "topic-start":
|
|
135
|
+
track.current = shorten(event.title);
|
|
136
|
+
visSpin.setLines(linesFor(event.mode, provider.name));
|
|
137
|
+
break;
|
|
138
|
+
case "topic-fallback":
|
|
139
|
+
track.current = shorten(event.title);
|
|
140
|
+
visSpin.setLines(linesFor("diagram", provider.name));
|
|
141
|
+
break;
|
|
142
|
+
case "topic-shot":
|
|
143
|
+
track.current = shorten(event.title);
|
|
144
|
+
visSpin.setLines(linesFor("shoot", provider.name));
|
|
145
|
+
break;
|
|
146
|
+
case "topic-done":
|
|
147
|
+
track.done = event.done;
|
|
148
|
+
track.total = event.total;
|
|
149
|
+
if (event.done === event.total)
|
|
150
|
+
track.current = "";
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
refresh();
|
|
154
|
+
};
|
|
93
155
|
try {
|
|
94
156
|
const visual = await runVisualPipeline({
|
|
95
157
|
cwd,
|
|
@@ -98,21 +160,28 @@ async function run(type, opts) {
|
|
|
98
160
|
diff,
|
|
99
161
|
provider,
|
|
100
162
|
modelCheap: cheapModel,
|
|
101
|
-
noImages: noImages && !
|
|
163
|
+
noImages: noImages && !manualShots,
|
|
102
164
|
before: opts.before,
|
|
103
165
|
after: opts.after,
|
|
166
|
+
theme: resolveTheme(config.visual ?? {}),
|
|
167
|
+
maxTopics: config.visual?.maxTopics,
|
|
168
|
+
concurrency: config.visual?.concurrency,
|
|
169
|
+
visualFiles,
|
|
104
170
|
verbose: opts.verbose,
|
|
105
171
|
log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
|
|
172
|
+
onProgress,
|
|
106
173
|
});
|
|
107
174
|
imagePath = visual.imagePath;
|
|
108
175
|
images = visual.images;
|
|
109
176
|
imageMeta = visual.imageMeta;
|
|
110
|
-
if (visual.warning) {
|
|
177
|
+
if (visual.warning && images.length === 0) {
|
|
111
178
|
visSpin.succeed("visuals skipped");
|
|
112
179
|
ui.note(visual.warning);
|
|
113
180
|
}
|
|
114
181
|
else if (imagePath) {
|
|
115
|
-
visSpin.succeed("
|
|
182
|
+
visSpin.succeed(`${images.length} visual${images.length === 1 ? "" : "s"} ready`);
|
|
183
|
+
if (visual.warning)
|
|
184
|
+
ui.note(visual.warning);
|
|
116
185
|
}
|
|
117
186
|
else {
|
|
118
187
|
visSpin.succeed("no visual (ok)");
|
|
@@ -120,7 +189,7 @@ async function run(type, opts) {
|
|
|
120
189
|
}
|
|
121
190
|
catch (err) {
|
|
122
191
|
const msg = err instanceof Error ? err.message : String(err);
|
|
123
|
-
if (err instanceof ChromeError &&
|
|
192
|
+
if (err instanceof ChromeError && manualShots) {
|
|
124
193
|
visSpin.fail(msg);
|
|
125
194
|
return 1;
|
|
126
195
|
}
|
|
@@ -173,6 +242,13 @@ async function run(type, opts) {
|
|
|
173
242
|
process.stdout.write(artifact);
|
|
174
243
|
return 0;
|
|
175
244
|
}
|
|
245
|
+
function describeDiff(fileCount) {
|
|
246
|
+
return `${fileCount} file${fileCount === 1 ? "" : "s"}`;
|
|
247
|
+
}
|
|
248
|
+
function shorten(text, max = 28) {
|
|
249
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
250
|
+
return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
|
|
251
|
+
}
|
|
176
252
|
function addShared(cmd) {
|
|
177
253
|
return cmd
|
|
178
254
|
.option("-r, --range <range>", "git range, e.g. main..feature")
|
|
@@ -183,6 +259,9 @@ function addShared(cmd) {
|
|
|
183
259
|
.option("--no-images", "skip image generation/composition")
|
|
184
260
|
.option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
|
|
185
261
|
.option("--model-cheap <id>", "model for visual generation")
|
|
262
|
+
.option("--theme <name>", "visual palette: midnight | daylight")
|
|
263
|
+
.option("--accent <color>", "override the theme accent color")
|
|
264
|
+
.option("--max-visuals <n>", "cap generated visuals (1-5)")
|
|
186
265
|
.option("-v, --verbose", "verbose logging", false);
|
|
187
266
|
}
|
|
188
267
|
async function main() {
|
package/dist/providers/claude.js
CHANGED
|
@@ -1,58 +1,75 @@
|
|
|
1
1
|
import { execa } from "execa";
|
|
2
2
|
const TIMEOUT_MS = 120_000;
|
|
3
|
+
/**
|
|
4
|
+
* farq only ever asks for JSON back, so the user's MCP servers, skills, hooks
|
|
5
|
+
* and project CLAUDE.md are pure startup cost — and a source of drift in the
|
|
6
|
+
* output. Skipping them roughly halves per-call latency.
|
|
7
|
+
*/
|
|
8
|
+
const FAST_ARGS = ["--strict-mcp-config", "--setting-sources", ""];
|
|
9
|
+
/** Tried in order; each falls back to the next when the CLI rejects a flag. */
|
|
10
|
+
const ARG_VARIANTS = [
|
|
11
|
+
FAST_ARGS,
|
|
12
|
+
[],
|
|
13
|
+
// Empty allowedTools hangs on some versions, so it stays the last resort.
|
|
14
|
+
["--allowedTools", ""],
|
|
15
|
+
];
|
|
16
|
+
export const UNSUPPORTED_FLAG = /unknown option|unknown argument|unrecognized|allowedTools|setting-sources|strict-mcp-config/i;
|
|
3
17
|
export async function complete(prompt, options = {}) {
|
|
4
18
|
const baseArgs = ["-p", "--output-format", "json", "--max-turns", "1"];
|
|
5
19
|
if (options.model)
|
|
6
20
|
baseArgs.push("--model", options.model);
|
|
7
|
-
// Prefer without allowedTools first — empty allowedTools hangs/fails on some versions.
|
|
8
|
-
const attempts = [[...baseArgs], [...baseArgs, "--allowedTools", ""]];
|
|
9
21
|
let lastErr;
|
|
10
|
-
for (
|
|
22
|
+
for (let i = 0; i < ARG_VARIANTS.length; i++) {
|
|
23
|
+
const canRetry = i < ARG_VARIANTS.length - 1;
|
|
24
|
+
const args = [...baseArgs, ...ARG_VARIANTS[i]];
|
|
25
|
+
let result;
|
|
11
26
|
try {
|
|
12
|
-
|
|
27
|
+
result = await execa("claude", args, {
|
|
13
28
|
input: prompt,
|
|
14
29
|
timeout: TIMEOUT_MS,
|
|
15
30
|
reject: false,
|
|
16
31
|
stdout: "pipe",
|
|
17
32
|
stderr: "pipe",
|
|
18
33
|
});
|
|
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
34
|
}
|
|
39
35
|
catch (err) {
|
|
40
36
|
const e = err;
|
|
41
|
-
if (e.timedOut
|
|
42
|
-
throw
|
|
37
|
+
if (e.timedOut)
|
|
38
|
+
throw notResponding();
|
|
39
|
+
if (canRetry && UNSUPPORTED_FLAG.test(e.message ?? "")) {
|
|
40
|
+
lastErr = err;
|
|
41
|
+
continue;
|
|
43
42
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
44
|
+
}
|
|
45
|
+
if (result.timedOut)
|
|
46
|
+
throw notResponding();
|
|
47
|
+
const text = (result.stdout || result.stderr || "").trim();
|
|
48
|
+
const diagnostics = `${result.stderr ?? ""} ${text}`;
|
|
49
|
+
if (!text && result.exitCode !== 0) {
|
|
50
|
+
const err = new Error(`claude failed (exit ${result.exitCode}): ${(result.stderr || "no output").trim()}`);
|
|
51
|
+
if (canRetry && UNSUPPORTED_FLAG.test(diagnostics)) {
|
|
52
|
+
lastErr = err;
|
|
53
|
+
continue;
|
|
51
54
|
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return parseClaudeOutput(text);
|
|
59
|
+
}
|
|
60
|
+
catch (parseErr) {
|
|
61
|
+
if (canRetry && UNSUPPORTED_FLAG.test(diagnostics)) {
|
|
62
|
+
lastErr = parseErr;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw parseErr;
|
|
52
66
|
}
|
|
53
67
|
}
|
|
54
68
|
throw lastErr instanceof Error ? lastErr : new Error("claude failed");
|
|
55
69
|
}
|
|
70
|
+
function notResponding() {
|
|
71
|
+
return new Error("claude did not respond — check that it's authenticated (`claude` login)");
|
|
72
|
+
}
|
|
56
73
|
function parseClaudeOutput(stdout) {
|
|
57
74
|
try {
|
|
58
75
|
const envelope = JSON.parse(stdout);
|