@ahmedalbarghouti/farq 0.0.2 → 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 -13
- 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 +116 -33
- package/dist/open-pr.d.ts +6 -1
- package/dist/open-pr.js +42 -16
- 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/render/pr.d.ts +6 -0
- package/dist/render/pr.js +29 -7
- package/dist/schema.d.ts +40 -7
- package/dist/schema.js +14 -1
- package/dist/summarize.js +7 -0
- package/dist/template.d.ts +6 -0
- package/dist/template.js +40 -19
- 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 +21 -2
- package/dist/visual/cluster.d.ts +29 -0
- package/dist/visual/cluster.js +204 -0
- package/dist/visual/compose.d.ts +8 -1
- package/dist/visual/compose.js +17 -32
- 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 +8 -0
- package/dist/visual/diagram.js +36 -14
- package/dist/visual/mockup.d.ts +16 -11
- package/dist/visual/mockup.js +45 -22
- package/dist/visual/pipeline.d.ts +35 -1
- package/dist/visual/pipeline.js +191 -69
- package/dist/visual/viewport.d.ts +16 -0
- package/dist/visual/viewport.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
One command. No servers. No API keys. Auth stays with your local `claude` or `opencode` CLI.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx @ahmedalbarghouti/farq@0.0.
|
|
8
|
+
npx @ahmedalbarghouti/farq@0.0.3 pr # title + markdown body (+ image when feasible)
|
|
9
9
|
npx farq slack # Slack mrkdwn daily update
|
|
10
10
|
npx farq json # structured JSON
|
|
11
11
|
npx farq pr --open # fill PR template + create with gh
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
> **0.0.
|
|
14
|
+
> **0.0.3** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ farq --help
|
|
|
23
23
|
Or run without installing:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx @ahmedalbarghouti/farq@0.0.
|
|
26
|
+
npx @ahmedalbarghouti/farq@0.0.3 --help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Generated images land in a **user cache directory outside the repo** by default (no `.gitignore` change needed). Use `--out .farq` if you want them in-tree.
|
|
@@ -76,7 +76,7 @@ farq pr --open
|
|
|
76
76
|
|
|
77
77
|
1. Reads `.github` PR template if present and fills known sections
|
|
78
78
|
2. Infers title style from recent merged PR titles when `gh` works
|
|
79
|
-
3. Best-effort uploads
|
|
79
|
+
3. Best-effort uploads composed image(s) as prerelease assets and embeds the URL(s)
|
|
80
80
|
4. Creates the PR with `gh pr create`, or **updates** title/body with `gh pr edit` if one already exists for the branch, then opens it in the browser
|
|
81
81
|
|
|
82
82
|
On the default branch (`main` / `master` / repo default), `--open` skips create and still prints the artifact. Titles are capped at GitHub's **256** character limit (overflow goes into the body).
|
|
@@ -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. UI markup gets a mockup attempt; everything else gets a small concept flowchart
|
|
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,22 +165,36 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
|
|
|
141
165
|
|
|
142
166
|
Progress → **stderr**. Artifact → **stdout**.
|
|
143
167
|
|
|
144
|
-
##
|
|
168
|
+
## Progress output
|
|
145
169
|
|
|
146
|
-
|
|
170
|
+
Each stage is numbered, names what it is doing, and reports how long it took:
|
|
147
171
|
|
|
148
|
-
```bash
|
|
149
|
-
git tag v0.0.2
|
|
150
|
-
git push origin v0.0.2
|
|
151
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`).
|
|
152
190
|
|
|
153
|
-
|
|
191
|
+
If a run still feels slow, the lever with the most travel is `--model-cheap` (for example `haiku`), followed by `--max-visuals 1`.
|
|
154
192
|
|
|
155
193
|
## Repository ops
|
|
156
194
|
|
|
157
195
|
- **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
|
|
158
196
|
- **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
|
|
159
|
-
- **Releases** are tag-triggered: create `v0.0.
|
|
197
|
+
- **Releases** are tag-triggered: create `v0.0.3` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
|
|
160
198
|
- **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
|
|
161
199
|
|
|
162
200
|
## Roadmap
|
|
@@ -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 {
|
|
@@ -87,8 +108,50 @@ async function run(type, opts) {
|
|
|
87
108
|
: undefined;
|
|
88
109
|
let imagePath = null;
|
|
89
110
|
let images = [];
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
let imageMeta = [];
|
|
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
|
+
};
|
|
92
155
|
try {
|
|
93
156
|
const visual = await runVisualPipeline({
|
|
94
157
|
cwd,
|
|
@@ -97,20 +160,28 @@ async function run(type, opts) {
|
|
|
97
160
|
diff,
|
|
98
161
|
provider,
|
|
99
162
|
modelCheap: cheapModel,
|
|
100
|
-
noImages: noImages && !
|
|
163
|
+
noImages: noImages && !manualShots,
|
|
101
164
|
before: opts.before,
|
|
102
165
|
after: opts.after,
|
|
166
|
+
theme: resolveTheme(config.visual ?? {}),
|
|
167
|
+
maxTopics: config.visual?.maxTopics,
|
|
168
|
+
concurrency: config.visual?.concurrency,
|
|
169
|
+
visualFiles,
|
|
103
170
|
verbose: opts.verbose,
|
|
104
171
|
log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
|
|
172
|
+
onProgress,
|
|
105
173
|
});
|
|
106
174
|
imagePath = visual.imagePath;
|
|
107
175
|
images = visual.images;
|
|
108
|
-
|
|
176
|
+
imageMeta = visual.imageMeta;
|
|
177
|
+
if (visual.warning && images.length === 0) {
|
|
109
178
|
visSpin.succeed("visuals skipped");
|
|
110
179
|
ui.note(visual.warning);
|
|
111
180
|
}
|
|
112
181
|
else if (imagePath) {
|
|
113
|
-
visSpin.succeed("
|
|
182
|
+
visSpin.succeed(`${images.length} visual${images.length === 1 ? "" : "s"} ready`);
|
|
183
|
+
if (visual.warning)
|
|
184
|
+
ui.note(visual.warning);
|
|
114
185
|
}
|
|
115
186
|
else {
|
|
116
187
|
visSpin.succeed("no visual (ok)");
|
|
@@ -118,7 +189,7 @@ async function run(type, opts) {
|
|
|
118
189
|
}
|
|
119
190
|
catch (err) {
|
|
120
191
|
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
-
if (err instanceof ChromeError &&
|
|
192
|
+
if (err instanceof ChromeError && manualShots) {
|
|
122
193
|
visSpin.fail(msg);
|
|
123
194
|
return 1;
|
|
124
195
|
}
|
|
@@ -126,10 +197,10 @@ async function run(type, opts) {
|
|
|
126
197
|
ui.note(`${msg} (continuing without image)`);
|
|
127
198
|
}
|
|
128
199
|
}
|
|
129
|
-
const
|
|
200
|
+
const prImages = (imageMeta.length > 0 ? imageMeta : images.map((p) => ({ path: p, title: "before / after" }))).map((img) => ({ path: displayImageRef(cwd, img.path), title: img.title }));
|
|
130
201
|
let artifact = "";
|
|
131
202
|
if (type === "pr") {
|
|
132
|
-
artifact = renderPr({ summary,
|
|
203
|
+
artifact = renderPr({ summary, images: prImages });
|
|
133
204
|
}
|
|
134
205
|
else if (type === "slack") {
|
|
135
206
|
artifact = renderSlack(summary);
|
|
@@ -147,6 +218,8 @@ async function run(type, opts) {
|
|
|
147
218
|
? artifact.split("\n\n").slice(1).join("\n\n")
|
|
148
219
|
: artifact,
|
|
149
220
|
imagePath,
|
|
221
|
+
imagePaths: images,
|
|
222
|
+
imageTitles: imageMeta.map((m) => m.title),
|
|
150
223
|
});
|
|
151
224
|
if (result.skipped) {
|
|
152
225
|
openSpin.succeed(result.reason);
|
|
@@ -169,6 +242,13 @@ async function run(type, opts) {
|
|
|
169
242
|
process.stdout.write(artifact);
|
|
170
243
|
return 0;
|
|
171
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
|
+
}
|
|
172
252
|
function addShared(cmd) {
|
|
173
253
|
return cmd
|
|
174
254
|
.option("-r, --range <range>", "git range, e.g. main..feature")
|
|
@@ -179,6 +259,9 @@ function addShared(cmd) {
|
|
|
179
259
|
.option("--no-images", "skip image generation/composition")
|
|
180
260
|
.option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
|
|
181
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)")
|
|
182
265
|
.option("-v, --verbose", "verbose logging", false);
|
|
183
266
|
}
|
|
184
267
|
async function main() {
|
|
@@ -186,7 +269,7 @@ async function main() {
|
|
|
186
269
|
program
|
|
187
270
|
.name("farq")
|
|
188
271
|
.description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
|
|
189
|
-
.version("0.0.
|
|
272
|
+
.version("0.0.3");
|
|
190
273
|
addShared(program
|
|
191
274
|
.command("pr", { isDefault: true })
|
|
192
275
|
.description("PR title + body markdown")
|
package/dist/open-pr.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type OpenPrResult = {
|
|
|
10
10
|
title: string;
|
|
11
11
|
body: string;
|
|
12
12
|
imageUrl?: string | null;
|
|
13
|
+
imageUrls?: string[];
|
|
13
14
|
};
|
|
14
15
|
/** Prerelease tag used to host a branch's composed PNG. */
|
|
15
16
|
export declare function assetTagForBranch(branch: string): string;
|
|
@@ -26,11 +27,15 @@ export declare function openPullRequest(options: {
|
|
|
26
27
|
summary: ChangeSummary;
|
|
27
28
|
bodyMarkdown: string;
|
|
28
29
|
imagePath?: string | null;
|
|
30
|
+
imagePaths?: string[];
|
|
31
|
+
imageTitles?: string[];
|
|
29
32
|
}): Promise<OpenPrResult>;
|
|
30
33
|
/**
|
|
31
34
|
* Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
|
|
32
35
|
* Always keeps `keepTag` (the just-uploaded asset).
|
|
33
36
|
*/
|
|
34
37
|
export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
|
|
35
|
-
/** Best-effort: prerelease asset URL for
|
|
38
|
+
/** Best-effort: prerelease asset URL for one PNG. */
|
|
36
39
|
export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
|
|
40
|
+
/** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
|
|
41
|
+
export declare function uploadPrImages(cwd: string, imagePaths: string[], branch: string): Promise<string[]>;
|