@ahmedalbarghouti/farq 0.0.1 → 0.0.2

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 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.1 pr # title + markdown body (+ image when feasible)
8
+ npx @ahmedalbarghouti/farq@0.0.2 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.1** — first public release. Expect rough edges; the CLI surface (`pr` / `slack` / `json`) is the stable bit.
14
+ > **0.0.2** — 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,10 +23,10 @@ farq --help
23
23
  Or run without installing:
24
24
 
25
25
  ```bash
26
- npx @ahmedalbarghouti/farq@0.0.1 --help
26
+ npx @ahmedalbarghouti/farq@0.0.2 --help
27
27
  ```
28
28
 
29
- Add `.farq/` to your repo ignore file (generated HTML/PNG land there).
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.
30
30
 
31
31
  ## Requirements
32
32
 
@@ -95,7 +95,7 @@ Validated change summary plus an `images` array of produced file paths.
95
95
  - Generated compositions include 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
- - A local `.farq/before-after.png` path will not render on GitHub until the file is attached or uploaded. `--open` tries a best-effort upload.
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
100
  ## Config
101
101
 
@@ -126,7 +126,7 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
126
126
  | `-t, --tone` | `technical` \| `client` |
127
127
  | `--before` / `--after` | manual screenshots (skips generation) |
128
128
  | `--no-images` | skip visuals |
129
- | `-o, --out` | image output dir (default `.farq/`) |
129
+ | `-o, --out` | image output dir (default: OS user cache, outside the repo) |
130
130
  | `--model-cheap` | cheap model id for visuals |
131
131
  | `-v, --verbose` | verbose logs (incl. `feasible: false` reasons) |
132
132
  | `--open` | (`pr` only) create PR with `gh` |
@@ -146,17 +146,18 @@ Progress → **stderr**. Artifact → **stdout**.
146
146
  Tag-triggered publish via GitHub Actions (npm provenance):
147
147
 
148
148
  ```bash
149
- git tag v0.0.1
150
- git push origin v0.0.1
149
+ git tag v0.0.2
150
+ git push origin v0.0.2
151
151
  ```
152
152
 
153
- Requires repo secret `NPM_TOKEN`. Package name: `farq`.
153
+ Requires repo secret `NPM_TOKEN`. Package name: `@ahmedalbarghouti/farq`.
154
154
 
155
155
  ## Repository ops
156
156
 
157
157
  - **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
158
158
  - **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
159
- - **Releases** are tag-triggered: create `v0.0.1` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
159
+ - **Releases** are tag-triggered: create `v0.0.2` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
160
+ - **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
160
161
 
161
162
  ## Roadmap
162
163
 
package/dist/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
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";
3
+ import { loadConfig, mergeConfig, } from "./config.js";
4
+ import { gatherDiff } from "./git.js";
6
5
  import { resolveProvider } from "./providers/index.js";
7
- import { summarize, SummarizeError } from "./summarize.js";
6
+ import { summarize } from "./summarize.js";
8
7
  import { inferTitleConvention } from "./title.js";
9
8
  import { fetchRecentPrTitles, openPullRequest } from "./open-pr.js";
10
9
  import { runVisualPipeline } from "./visual/pipeline.js";
@@ -12,8 +11,11 @@ import { ChromeError } from "./visual/chrome.js";
12
11
  import { renderPr } from "./render/pr.js";
13
12
  import { renderSlack } from "./render/slack.js";
14
13
  import { renderJson } from "./render/json.js";
14
+ import { createUi } from "./ui/index.js";
15
+ import { defaultOutDir, displayImageRef } from "./paths.js";
15
16
  async function run(type, opts) {
16
17
  const cwd = process.cwd();
18
+ const ui = createUi();
17
19
  const fileConfig = loadConfig({ cwd });
18
20
  const flagConfig = {
19
21
  provider: opts.provider,
@@ -26,34 +28,30 @@ async function run(type, opts) {
26
28
  : undefined,
27
29
  };
28
30
  const config = mergeConfig(fileConfig, flagConfig);
29
- const log = (msg) => {
30
- console.error(msg);
31
- };
32
31
  let provider;
33
32
  try {
34
33
  provider = await resolveProvider({
35
34
  flag: config.provider,
36
35
  config,
37
- log,
36
+ log: (msg) => ui.note(msg),
38
37
  });
39
38
  }
40
39
  catch (err) {
41
- log(err instanceof Error ? err.message : String(err));
40
+ ui.error(err instanceof Error ? err.message : String(err));
42
41
  return 1;
43
42
  }
44
43
  const tone = config.tone ?? "technical";
45
- const imagesEnabled = type === "pr" ? !opts.noImages : false;
44
+ const noImages = opts.noImages === true || opts.images === false;
45
+ const imagesEnabled = type === "pr" ? !noImages : false;
46
46
  let diff;
47
+ const diffSpin = ui.stage("diff");
47
48
  try {
48
- log("farq: gathering diff…");
49
49
  diff = await gatherDiff({ cwd, range: opts.range });
50
+ diffSpin.succeed("diff ready");
50
51
  }
51
52
  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));
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ diffSpin.fail(msg);
57
55
  return 1;
58
56
  }
59
57
  let titleBlurb = "";
@@ -66,8 +64,8 @@ async function run(type, opts) {
66
64
  // optional
67
65
  }
68
66
  }
69
- log(`farq: summarizing with ${provider.name}…`);
70
67
  let summary;
68
+ const sumSpin = ui.stage("summarize", provider.name);
71
69
  try {
72
70
  summary = await summarize({
73
71
  provider,
@@ -75,13 +73,11 @@ async function run(type, opts) {
75
73
  tone,
76
74
  titleConventionBlurb: titleBlurb || undefined,
77
75
  });
76
+ sumSpin.succeed(`summarized with ${provider.name}`);
78
77
  }
79
78
  catch (err) {
80
- if (err instanceof SummarizeError) {
81
- log(err.message);
82
- return 2;
83
- }
84
- log(err instanceof Error ? err.message : String(err));
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ sumSpin.fail(msg);
85
81
  return 2;
86
82
  }
87
83
  const cheapModel = provider.name === "claude"
@@ -92,35 +88,45 @@ async function run(type, opts) {
92
88
  let imagePath = null;
93
89
  let images = [];
94
90
  if (imagesEnabled || (opts.before && opts.after)) {
95
- log("farq: visual pipeline…");
91
+ const visSpin = ui.stage("visual", provider.name);
96
92
  try {
97
93
  const visual = await runVisualPipeline({
98
94
  cwd,
99
- outDir: opts.out ?? ".farq",
95
+ outDir: opts.out ?? defaultOutDir(cwd),
100
96
  summary,
101
97
  diff,
102
98
  provider,
103
99
  modelCheap: cheapModel,
104
- noImages: opts.noImages && !(opts.before && opts.after),
100
+ noImages: noImages && !(opts.before && opts.after),
105
101
  before: opts.before,
106
102
  after: opts.after,
107
103
  verbose: opts.verbose,
108
- log: opts.verbose ? log : () => undefined,
104
+ log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
109
105
  });
110
106
  imagePath = visual.imagePath;
111
107
  images = visual.images;
112
- if (visual.warning)
113
- log(`farq: ${visual.warning}`);
108
+ if (visual.warning) {
109
+ visSpin.succeed("visuals skipped");
110
+ ui.note(visual.warning);
111
+ }
112
+ else if (imagePath) {
113
+ visSpin.succeed("visuals ready");
114
+ }
115
+ else {
116
+ visSpin.succeed("no visual (ok)");
117
+ }
114
118
  }
115
119
  catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
116
121
  if (err instanceof ChromeError && opts.before && opts.after) {
117
- log(err.message);
122
+ visSpin.fail(msg);
118
123
  return 1;
119
124
  }
120
- log(`farq: ${err instanceof Error ? err.message : String(err)} (continuing without image)`);
125
+ visSpin.succeed("visuals skipped");
126
+ ui.note(`${msg} (continuing without image)`);
121
127
  }
122
128
  }
123
- const relImage = imagePath != null ? relative(cwd, resolve(imagePath)).replaceAll("\\", "/") : null;
129
+ const relImage = imagePath != null ? displayImageRef(cwd, imagePath) : null;
124
130
  let artifact = "";
125
131
  if (type === "pr") {
126
132
  artifact = renderPr({ summary, imagePath: relImage });
@@ -129,10 +135,10 @@ async function run(type, opts) {
129
135
  artifact = renderSlack(summary);
130
136
  }
131
137
  else {
132
- artifact = renderJson(summary, images.map((p) => relative(cwd, p).replaceAll("\\", "/")));
138
+ artifact = renderJson(summary, images.map((p) => displayImageRef(cwd, p)));
133
139
  }
134
140
  if (type === "pr" && opts.open) {
135
- log("farq: opening PR…");
141
+ const openSpin = ui.stage("open");
136
142
  try {
137
143
  const result = await openPullRequest({
138
144
  cwd,
@@ -143,16 +149,20 @@ async function run(type, opts) {
143
149
  imagePath,
144
150
  });
145
151
  if (result.skipped) {
146
- log(`farq: ${result.reason}`);
152
+ openSpin.succeed(result.reason);
147
153
  }
148
154
  else {
149
- log(`farq: PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
155
+ openSpin.succeed(`PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
150
156
  if (result.warning)
151
- log(`farq: ${result.warning}`);
157
+ ui.note(result.warning);
158
+ // Match GitHub body (hosted image URL, no local-path note).
159
+ artifact = `${result.title}\n\n${result.body}`.replace(/\n{3,}/g, "\n\n");
160
+ if (!artifact.endsWith("\n"))
161
+ artifact += "\n";
152
162
  }
153
163
  }
154
164
  catch (err) {
155
- log(err instanceof Error ? err.message : String(err));
165
+ openSpin.fail(err instanceof Error ? err.message : String(err));
156
166
  return 1;
157
167
  }
158
168
  }
@@ -167,7 +177,7 @@ function addShared(cmd) {
167
177
  .option("--before <path>", "manual before screenshot")
168
178
  .option("--after <path>", "manual after screenshot")
169
179
  .option("--no-images", "skip image generation/composition")
170
- .option("-o, --out <dir>", "output dir for images", ".farq")
180
+ .option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
171
181
  .option("--model-cheap <id>", "model for visual generation")
172
182
  .option("-v, --verbose", "verbose logging", false);
173
183
  }
@@ -176,11 +186,11 @@ async function main() {
176
186
  program
177
187
  .name("farq")
178
188
  .description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
179
- .version("0.0.1");
189
+ .version("0.0.2");
180
190
  addShared(program
181
191
  .command("pr", { isDefault: true })
182
192
  .description("PR title + body markdown")
183
- .option("--open", "create a GitHub PR with gh (template-aware)", false)
193
+ .option("--open", "create or update a GitHub PR with gh (template-aware)", false)
184
194
  .action(async (opts) => {
185
195
  process.exitCode = await run("pr", opts);
186
196
  }));
@@ -199,6 +209,7 @@ async function main() {
199
209
  await program.parseAsync(process.argv);
200
210
  }
201
211
  main().catch((err) => {
202
- console.error(err instanceof Error ? err.message : String(err));
212
+ const ui = createUi();
213
+ ui.error(err instanceof Error ? err.message : String(err));
203
214
  process.exitCode = 1;
204
215
  });
package/dist/open-pr.d.ts CHANGED
@@ -7,7 +7,12 @@ export type OpenPrResult = {
7
7
  action: "created" | "updated";
8
8
  url?: string;
9
9
  warning?: string;
10
+ title: string;
11
+ body: string;
12
+ imageUrl?: string | null;
10
13
  };
14
+ /** Prerelease tag used to host a branch's composed PNG. */
15
+ export declare function assetTagForBranch(branch: string): string;
11
16
  export declare function resolveDefaultBranch(cwd: string): Promise<string>;
12
17
  export declare function currentBranch(cwd: string): Promise<string>;
13
18
  export declare function fetchRecentPrTitles(cwd: string, limit?: number): Promise<string[]>;
@@ -22,5 +27,10 @@ export declare function openPullRequest(options: {
22
27
  bodyMarkdown: string;
23
28
  imagePath?: string | null;
24
29
  }): Promise<OpenPrResult>;
30
+ /**
31
+ * Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
32
+ * Always keeps `keepTag` (the just-uploaded asset).
33
+ */
34
+ export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
25
35
  /** Best-effort: prerelease asset URL for the PNG. */
26
36
  export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
package/dist/open-pr.js CHANGED
@@ -3,17 +3,29 @@ import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { execa } from "execa";
5
5
  import { fillPrTemplate, findPrTemplate } from "./template.js";
6
+ import { resolveGh } from "./tools.js";
6
7
  const GH_TIMEOUT = 60_000;
8
+ /** Prerelease tag used to host a branch's composed PNG. */
9
+ export function assetTagForBranch(branch) {
10
+ return `farq-assets-${branch.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}`;
11
+ }
12
+ async function gh(cwd, args, options = {}) {
13
+ return execa(resolveGh(), args, {
14
+ cwd,
15
+ timeout: GH_TIMEOUT,
16
+ reject: options.reject ?? true,
17
+ });
18
+ }
7
19
  export async function resolveDefaultBranch(cwd) {
8
20
  try {
9
- const { stdout } = await execa("gh", [
21
+ const { stdout } = await gh(cwd, [
10
22
  "repo",
11
23
  "view",
12
24
  "--json",
13
25
  "defaultBranchRef",
14
26
  "--jq",
15
27
  ".defaultBranchRef.name",
16
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
28
+ ]);
17
29
  if (stdout.trim())
18
30
  return stdout.trim();
19
31
  }
@@ -45,7 +57,7 @@ export async function currentBranch(cwd) {
45
57
  }
46
58
  export async function fetchRecentPrTitles(cwd, limit = 20) {
47
59
  try {
48
- const { stdout } = await execa("gh", [
60
+ const { stdout } = await gh(cwd, [
49
61
  "pr",
50
62
  "list",
51
63
  "--state",
@@ -56,7 +68,7 @@ export async function fetchRecentPrTitles(cwd, limit = 20) {
56
68
  "title",
57
69
  "--jq",
58
70
  ".[].title",
59
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
71
+ ]);
60
72
  return stdout
61
73
  .split("\n")
62
74
  .map((s) => s.trim())
@@ -69,7 +81,7 @@ export async function fetchRecentPrTitles(cwd, limit = 20) {
69
81
  /** Existing open PR for the current branch, if any. */
70
82
  export async function findExistingPr(cwd) {
71
83
  try {
72
- const { stdout, exitCode } = await execa("gh", ["pr", "view", "--json", "number,url"], { cwd, timeout: GH_TIMEOUT, reject: false });
84
+ const { stdout, exitCode } = await gh(cwd, ["pr", "view", "--json", "number,url"], { reject: false });
73
85
  if (exitCode !== 0 || !stdout.trim())
74
86
  return null;
75
87
  const parsed = JSON.parse(stdout);
@@ -115,7 +127,7 @@ export async function openPullRequest(options) {
115
127
  try {
116
128
  const existing = await findExistingPr(cwd);
117
129
  if (existing) {
118
- await execa("gh", [
130
+ await gh(cwd, [
119
131
  "pr",
120
132
  "edit",
121
133
  String(existing.number),
@@ -123,13 +135,9 @@ export async function openPullRequest(options) {
123
135
  title,
124
136
  "--body-file",
125
137
  bodyFile,
126
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
138
+ ]);
127
139
  try {
128
- await execa("gh", ["pr", "view", "--web"], {
129
- cwd,
130
- timeout: GH_TIMEOUT,
131
- reject: false,
132
- });
140
+ await gh(cwd, ["pr", "view", "--web"], { reject: false });
133
141
  }
134
142
  catch {
135
143
  // non-fatal
@@ -139,15 +147,21 @@ export async function openPullRequest(options) {
139
147
  action: "updated",
140
148
  url: existing.url,
141
149
  warning,
150
+ title,
151
+ body,
152
+ imageUrl,
142
153
  };
143
154
  }
144
- const created = await execa("gh", ["pr", "create", "--title", title, "--body-file", bodyFile], { cwd, timeout: GH_TIMEOUT, reject: true });
155
+ const created = await gh(cwd, [
156
+ "pr",
157
+ "create",
158
+ "--title",
159
+ title,
160
+ "--body-file",
161
+ bodyFile,
162
+ ]);
145
163
  try {
146
- await execa("gh", ["pr", "view", "--web"], {
147
- cwd,
148
- timeout: GH_TIMEOUT,
149
- reject: false,
150
- });
164
+ await gh(cwd, ["pr", "view", "--web"], { reject: false });
151
165
  }
152
166
  catch {
153
167
  // non-fatal
@@ -157,6 +171,9 @@ export async function openPullRequest(options) {
157
171
  action: "created",
158
172
  url: created.stdout?.trim() || undefined,
159
173
  warning,
174
+ title,
175
+ body,
176
+ imageUrl,
160
177
  };
161
178
  }
162
179
  catch (err) {
@@ -167,20 +184,59 @@ export async function openPullRequest(options) {
167
184
  rmSync(dir, { recursive: true, force: true });
168
185
  }
169
186
  }
187
+ /**
188
+ * Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
189
+ * Always keeps `keepTag` (the just-uploaded asset).
190
+ */
191
+ export async function pruneOrphanedFarqAssets(cwd, keepTag) {
192
+ const deleted = [];
193
+ try {
194
+ const { stdout: releasesOut, exitCode: relCode } = await gh(cwd, ["release", "list", "--limit", "100", "--json", "tagName,isPrerelease"], { reject: false });
195
+ if (relCode !== 0 || !releasesOut.trim())
196
+ return deleted;
197
+ const releases = JSON.parse(releasesOut);
198
+ const candidates = releases.filter((r) => r.isPrerelease &&
199
+ r.tagName.startsWith("farq-assets-") &&
200
+ r.tagName !== keepTag);
201
+ if (candidates.length === 0)
202
+ return deleted;
203
+ const { stdout: prsOut, exitCode: prCode } = await gh(cwd, ["pr", "list", "--state", "open", "--limit", "100", "--json", "headRefName"], { reject: false });
204
+ const openTags = new Set();
205
+ if (prCode === 0 && prsOut.trim()) {
206
+ const prs = JSON.parse(prsOut);
207
+ for (const pr of prs) {
208
+ openTags.add(assetTagForBranch(pr.headRefName));
209
+ }
210
+ }
211
+ for (const rel of candidates) {
212
+ if (openTags.has(rel.tagName))
213
+ continue;
214
+ try {
215
+ await gh(cwd, ["release", "delete", rel.tagName, "--yes", "--cleanup-tag"], { reject: false });
216
+ deleted.push(rel.tagName);
217
+ }
218
+ catch {
219
+ // best-effort
220
+ }
221
+ }
222
+ }
223
+ catch {
224
+ // best-effort — never fail --open for prune
225
+ }
226
+ return deleted;
227
+ }
170
228
  /** Best-effort: prerelease asset URL for the PNG. */
171
229
  export async function uploadPrImage(cwd, imagePath, branch) {
172
- const tag = `farq-assets-${branch.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}`;
230
+ const tag = assetTagForBranch(branch);
173
231
  try {
174
- await execa("gh", ["release", "delete", tag, "--yes", "--cleanup-tag"], {
175
- cwd,
176
- timeout: GH_TIMEOUT,
232
+ await gh(cwd, ["release", "delete", tag, "--yes", "--cleanup-tag"], {
177
233
  reject: false,
178
234
  });
179
235
  }
180
236
  catch {
181
237
  // ok if missing
182
238
  }
183
- await execa("gh", [
239
+ await gh(cwd, [
184
240
  "release",
185
241
  "create",
186
242
  tag,
@@ -190,10 +246,19 @@ export async function uploadPrImage(cwd, imagePath, branch) {
190
246
  "--notes",
191
247
  "Auto-uploaded by farq for PR description embedding.",
192
248
  "--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 });
249
+ ]);
250
+ const { stdout } = await gh(cwd, [
251
+ "release",
252
+ "view",
253
+ tag,
254
+ "--json",
255
+ "assets",
256
+ "--jq",
257
+ ".assets[0].url",
258
+ ]);
195
259
  const url = stdout.trim();
196
260
  if (!url)
197
261
  throw new Error("no asset URL returned");
262
+ await pruneOrphanedFarqAssets(cwd, tag);
198
263
  return url;
199
264
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Default image output dir — outside the repo so generated files
3
+ * never require a `.gitignore` entry.
4
+ *
5
+ * Override with `FARQ_CACHE_DIR` (root) or `--out <dir>`.
6
+ */
7
+ export declare function defaultOutDir(cwd: string, env?: NodeJS.ProcessEnv): string;
8
+ export declare function cacheRoot(env?: NodeJS.ProcessEnv): string;
9
+ /**
10
+ * Path shown in markdown: repo-relative when inside cwd, else absolute.
11
+ */
12
+ export declare function displayImageRef(cwd: string, imagePath: string): string;
package/dist/paths.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ /**
5
+ * Default image output dir — outside the repo so generated files
6
+ * never require a `.gitignore` entry.
7
+ *
8
+ * Override with `FARQ_CACHE_DIR` (root) or `--out <dir>`.
9
+ */
10
+ export function defaultOutDir(cwd, env = process.env) {
11
+ const abs = resolve(cwd);
12
+ const hash = createHash("sha256").update(abs).digest("hex").slice(0, 16);
13
+ return join(cacheRoot(env), hash);
14
+ }
15
+ export function cacheRoot(env = process.env) {
16
+ if (env.FARQ_CACHE_DIR)
17
+ return resolve(env.FARQ_CACHE_DIR);
18
+ if (process.platform === "win32") {
19
+ const local = env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
20
+ return join(local, "farq", "cache");
21
+ }
22
+ if (process.platform === "darwin") {
23
+ return join(homedir(), "Library", "Caches", "farq");
24
+ }
25
+ const xdg = env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
26
+ return join(xdg, "farq");
27
+ }
28
+ /**
29
+ * Path shown in markdown: repo-relative when inside cwd, else absolute.
30
+ */
31
+ export function displayImageRef(cwd, imagePath) {
32
+ const abs = resolve(imagePath);
33
+ const rel = relative(resolve(cwd), abs);
34
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) {
35
+ return rel.replaceAll("\\", "/");
36
+ }
37
+ return abs;
38
+ }
package/dist/render/pr.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { truncateTitle, GITHUB_TITLE_MAX } from "../title.js";
2
+ import { isHostedImageUrl, LOCAL_IMAGE_NOTE } from "../tools.js";
2
3
  const EMOJI = {
3
4
  feature: "\u2728",
4
5
  fix: "\uD83D\uDC1B",
@@ -23,8 +24,10 @@ export function renderPr(options) {
23
24
  out.push("");
24
25
  const alt = options.imageAlt ?? "before / after";
25
26
  out.push(`![${alt}](${options.imagePath})`);
26
- out.push("");
27
- out.push("_Local image path - attach the file when pasting into GitHub if it does not render._");
27
+ if (!isHostedImageUrl(options.imagePath)) {
28
+ out.push("");
29
+ out.push(LOCAL_IMAGE_NOTE);
30
+ }
28
31
  }
29
32
  out.push("");
30
33
  out.push("### Changes");
package/dist/template.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { truncateTitle, GITHUB_TITLE_MAX } from "./title.js";
4
+ import { stripLocalImageNote } from "./tools.js";
4
5
  export function findPrTemplate(cwd) {
5
6
  const single = join(cwd, ".github", "pull_request_template.md");
6
7
  if (existsSync(single))
@@ -24,9 +25,10 @@ export function fillPrTemplate(options) {
24
25
  if (overflow)
25
26
  body = overflow + "\n\n" + body;
26
27
  if (options.imageUrl) {
27
- const re = /\]\((?:\.\/)?\.farq\/[^)]+\)/;
28
+ // Swap any non-http(s) image target (cache path, .farq/, etc.).
29
+ const re = /!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/;
28
30
  if (re.test(body)) {
29
- body = body.replace(re, "](" + options.imageUrl + ")");
31
+ body = body.replace(re, "![$1](" + options.imageUrl + ")");
30
32
  }
31
33
  else {
32
34
  body =
@@ -35,6 +37,7 @@ export function fillPrTemplate(options) {
35
37
  ")\n\n" +
36
38
  body;
37
39
  }
40
+ body = stripLocalImageNote(body);
38
41
  }
39
42
  return { title, body };
40
43
  }
@@ -58,6 +61,7 @@ export function fillPrTemplate(options) {
58
61
  options.imageUrl +
59
62
  ")\n";
60
63
  }
64
+ body = stripLocalImageNote(body);
61
65
  }
62
66
  return { title, body };
63
67
  }
@@ -0,0 +1,21 @@
1
+ export declare class ToolNotFoundError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /** True when the image reference is already a hosted URL. */
5
+ export declare function isHostedImageUrl(ref: string): boolean;
6
+ /** Italic note appended for local-only image paths in PR markdown. */
7
+ export declare const LOCAL_IMAGE_NOTE = "_Local image path - attach the file when pasting into GitHub if it does not render._";
8
+ /** Strip the local-image attach note from PR markdown. */
9
+ export declare function stripLocalImageNote(body: string): string;
10
+ /**
11
+ * Resolve an executable: env override, then PATH, then extra candidate paths.
12
+ */
13
+ export declare function resolveExecutable(names: string[], options: {
14
+ env?: NodeJS.ProcessEnv;
15
+ envKey?: string;
16
+ extraPaths?: string[];
17
+ notFoundMessage: string;
18
+ }): string;
19
+ export declare function resolveGh(env?: NodeJS.ProcessEnv): string;
20
+ /** Reset cache (tests). */
21
+ export declare function resetGhCache(): void;
package/dist/tools.js ADDED
@@ -0,0 +1,95 @@
1
+ import { existsSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
3
+ import { platform } from "node:os";
4
+ export class ToolNotFoundError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "ToolNotFoundError";
8
+ }
9
+ }
10
+ /** True when the image reference is already a hosted URL. */
11
+ export function isHostedImageUrl(ref) {
12
+ return /^https?:\/\//i.test(ref.trim());
13
+ }
14
+ /** Italic note appended for local-only image paths in PR markdown. */
15
+ export const LOCAL_IMAGE_NOTE = "_Local image path - attach the file when pasting into GitHub if it does not render._";
16
+ /** Strip the local-image attach note from PR markdown. */
17
+ export function stripLocalImageNote(body) {
18
+ return body
19
+ .replace(/\n*_Local image path[^\n]*_\s*/g, "\n")
20
+ .replace(/\n{3,}/g, "\n\n");
21
+ }
22
+ /**
23
+ * Resolve an executable: env override, then PATH, then extra candidate paths.
24
+ */
25
+ export function resolveExecutable(names, options) {
26
+ const env = options.env ?? process.env;
27
+ if (options.envKey && env[options.envKey]) {
28
+ const forced = env[options.envKey];
29
+ if (existsSync(forced))
30
+ return forced;
31
+ throw new ToolNotFoundError(`${options.envKey} not found: ${forced}`);
32
+ }
33
+ const pathEnv = env.PATH ?? env.Path ?? "";
34
+ const dirs = pathEnv.split(delimiter).filter(Boolean);
35
+ const win = platform() === "win32";
36
+ const exts = win
37
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
38
+ .split(";")
39
+ .map((e) => e.toLowerCase())
40
+ : [""];
41
+ const tryName = (dir, name) => {
42
+ if (win) {
43
+ if (name.includes(".")) {
44
+ const p = join(dir, name);
45
+ return existsSync(p) ? p : null;
46
+ }
47
+ for (const ext of exts) {
48
+ const p = join(dir, name + ext);
49
+ if (existsSync(p))
50
+ return p;
51
+ }
52
+ return null;
53
+ }
54
+ const p = join(dir, name);
55
+ return existsSync(p) ? p : null;
56
+ };
57
+ for (const dir of dirs) {
58
+ for (const name of names) {
59
+ const hit = tryName(dir, name);
60
+ if (hit)
61
+ return hit;
62
+ }
63
+ }
64
+ for (const candidate of options.extraPaths ?? []) {
65
+ if (candidate && existsSync(candidate))
66
+ return candidate;
67
+ }
68
+ throw new ToolNotFoundError(options.notFoundMessage);
69
+ }
70
+ let cachedGh;
71
+ export function resolveGh(env = process.env) {
72
+ if (cachedGh)
73
+ return cachedGh;
74
+ const local = env.LOCALAPPDATA ?? "";
75
+ const pf = env.ProgramFiles ?? "C:\\Program Files";
76
+ const pf86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
77
+ cachedGh = resolveExecutable(["gh", "gh.exe"], {
78
+ env,
79
+ envKey: "GH_PATH",
80
+ extraPaths: [
81
+ join(pf, "GitHub CLI", "gh.exe"),
82
+ join(pf86, "GitHub CLI", "gh.exe"),
83
+ join(local, "Programs", "GitHub CLI", "gh.exe"),
84
+ "/usr/local/bin/gh",
85
+ "/opt/homebrew/bin/gh",
86
+ "/usr/bin/gh",
87
+ ],
88
+ notFoundMessage: "gh not found — install GitHub CLI (https://cli.github.com) or set GH_PATH",
89
+ });
90
+ return cachedGh;
91
+ }
92
+ /** Reset cache (tests). */
93
+ export function resetGhCache() {
94
+ cachedGh = undefined;
95
+ }
@@ -0,0 +1,2 @@
1
+ export { createUi, type Ui } from "./logger.js";
2
+ export { brand, isInteractive } from "./theme.js";
@@ -0,0 +1,2 @@
1
+ export { createUi } from "./logger.js";
2
+ export { brand, isInteractive } from "./theme.js";
@@ -0,0 +1,3 @@
1
+ import type { ProviderName } from "../config.js";
2
+ export type LineBank = "summarize" | "visual" | "open" | "diff";
3
+ export declare function linesFor(bank: LineBank, provider?: ProviderName): string[];
@@ -0,0 +1,48 @@
1
+ const SUMMARIZE = {
2
+ claude: [
3
+ "claude is reading the diff…",
4
+ "claude is grouping the mess…",
5
+ "claude is writing the blurb…",
6
+ ],
7
+ opencode: [
8
+ "opencode is reading the diff…",
9
+ "opencode is grouping the mess…",
10
+ "opencode is writing the blurb…",
11
+ ],
12
+ fake: [
13
+ "fake is vibing on hard mode…",
14
+ "fake is inventing nothing new…",
15
+ "fake is shipping canned vibes…",
16
+ ],
17
+ };
18
+ const VISUAL = {
19
+ claude: [
20
+ "claude is sketching a mockup…",
21
+ "claude is boxing a flowchart…",
22
+ "chrome is taking the shot…",
23
+ ],
24
+ opencode: [
25
+ "opencode is sketching a mockup…",
26
+ "opencode is boxing a flowchart…",
27
+ "chrome is taking the shot…",
28
+ ],
29
+ fake: [
30
+ "fake is drawing boxes…",
31
+ "fake is pretending to design…",
32
+ "chrome is taking the shot…",
33
+ ],
34
+ };
35
+ const OPEN = [
36
+ "talking to gh…",
37
+ "shipping the PR body…",
38
+ "almost there…",
39
+ ];
40
+ export function linesFor(bank, provider) {
41
+ if (bank === "diff") {
42
+ return ["gathering the diff…", "asking git politely…"];
43
+ }
44
+ if (bank === "open")
45
+ return OPEN;
46
+ const p = provider ?? "claude";
47
+ return bank === "summarize" ? SUMMARIZE[p] : VISUAL[p];
48
+ }
@@ -0,0 +1,12 @@
1
+ import type { ProviderName } from "../config.js";
2
+ import { type LineBank } from "./lines.js";
3
+ import { type SpinnerHandle } from "./spinner.js";
4
+ export type Ui = {
5
+ /** Plain note (no spinner). */
6
+ note: (text: string) => void;
7
+ /** Start a spinning stage with optional rotating witty lines. */
8
+ stage: (bank: LineBank, provider?: ProviderName) => SpinnerHandle;
9
+ error: (text: string) => void;
10
+ success: (text: string) => void;
11
+ };
12
+ export declare function createUi(): Ui;
@@ -0,0 +1,20 @@
1
+ import { linesFor } from "./lines.js";
2
+ import { startSpinner } from "./spinner.js";
3
+ import { brand, fail, muted, ok } from "./theme.js";
4
+ export function createUi() {
5
+ return {
6
+ note(text) {
7
+ process.stderr.write(`${brand()} ${muted(text)}\n`);
8
+ },
9
+ stage(bank, provider) {
10
+ const lines = linesFor(bank, provider);
11
+ return startSpinner(lines[0] ?? "working…", lines);
12
+ },
13
+ error(text) {
14
+ process.stderr.write(`${brand()} ${fail(text)}\n`);
15
+ },
16
+ success(text) {
17
+ process.stderr.write(`${brand()} ${ok(text)}\n`);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,7 @@
1
+ export type SpinnerHandle = {
2
+ update: (text: string) => void;
3
+ succeed: (text: string) => void;
4
+ fail: (text: string) => void;
5
+ stop: () => void;
6
+ };
7
+ export declare function startSpinner(initial: string, rotateLines?: string[]): SpinnerHandle;
@@ -0,0 +1,57 @@
1
+ import { brand, accent, isInteractive, muted, ok, fail } from "./theme.js";
2
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ export function startSpinner(initial, rotateLines) {
4
+ const interactive = isInteractive();
5
+ let text = initial;
6
+ let i = 0;
7
+ let lineIdx = 0;
8
+ let timer = null;
9
+ let lineTimer = null;
10
+ let stopped = false;
11
+ const render = () => {
12
+ if (!interactive || stopped)
13
+ return;
14
+ const frame = accent(FRAMES[i % FRAMES.length]);
15
+ process.stderr.write(`\r\x1b[2K${brand()} ${frame} ${text}`);
16
+ i++;
17
+ };
18
+ if (interactive) {
19
+ render();
20
+ timer = setInterval(render, 80);
21
+ if (rotateLines && rotateLines.length > 1) {
22
+ lineTimer = setInterval(() => {
23
+ lineIdx = (lineIdx + 1) % rotateLines.length;
24
+ text = rotateLines[lineIdx];
25
+ }, 2000);
26
+ }
27
+ }
28
+ else {
29
+ process.stderr.write(`${brand()} ${muted(text)}\n`);
30
+ }
31
+ return {
32
+ update(next) {
33
+ text = next;
34
+ if (!interactive)
35
+ process.stderr.write(`${brand()} ${muted(text)}\n`);
36
+ },
37
+ succeed(next) {
38
+ this.stop();
39
+ process.stderr.write(`${brand()} ${ok(next)}\n`);
40
+ },
41
+ fail(next) {
42
+ this.stop();
43
+ process.stderr.write(`${brand()} ${fail(next)}\n`);
44
+ },
45
+ stop() {
46
+ if (stopped)
47
+ return;
48
+ stopped = true;
49
+ if (timer)
50
+ clearInterval(timer);
51
+ if (lineTimer)
52
+ clearInterval(lineTimer);
53
+ if (interactive)
54
+ process.stderr.write("\r\x1b[2K");
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,6 @@
1
+ export declare function brand(): string;
2
+ export declare function accent(text: string): string;
3
+ export declare function ok(text: string): string;
4
+ export declare function fail(text: string): string;
5
+ export declare function muted(text: string): string;
6
+ export declare function isInteractive(): boolean;
@@ -0,0 +1,19 @@
1
+ import pc from "picocolors";
2
+ export function brand() {
3
+ return pc.dim("farq");
4
+ }
5
+ export function accent(text) {
6
+ return pc.cyan(text);
7
+ }
8
+ export function ok(text) {
9
+ return `${pc.cyan("✓")} ${pc.dim(text)}`;
10
+ }
11
+ export function fail(text) {
12
+ return `${pc.red("✗")} ${text}`;
13
+ }
14
+ export function muted(text) {
15
+ return pc.dim(text);
16
+ }
17
+ export function isInteractive() {
18
+ return Boolean(process.stderr.isTTY) && process.env.CI !== "true";
19
+ }
@@ -1,6 +1,7 @@
1
- import { existsSync } from "node:fs";
2
1
  import { execa } from "execa";
3
2
  import { platform } from "node:os";
3
+ import { join } from "node:path";
4
+ import { resolveExecutable, ToolNotFoundError, } from "../tools.js";
4
5
  const TIMEOUT_MS = 30_000;
5
6
  export class ChromeError extends Error {
6
7
  constructor(message) {
@@ -9,17 +10,15 @@ export class ChromeError extends Error {
9
10
  }
10
11
  }
11
12
  export function resolveChrome(env = process.env) {
12
- if (env.CHROME_PATH) {
13
- if (existsSync(env.CHROME_PATH))
14
- return env.CHROME_PATH;
15
- throw new ChromeError(`CHROME_PATH not found: ${env.CHROME_PATH}`);
16
- }
17
- const candidates = platform() === "win32"
13
+ const local = env.LOCALAPPDATA ?? "";
14
+ const pf = env.ProgramFiles ?? "C:\\Program Files";
15
+ const pf86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
16
+ const extraPaths = platform() === "win32"
18
17
  ? [
19
- "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
20
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
21
- `${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
22
- "C:\\Program Files\\Chromium\\Application\\chrome.exe",
18
+ join(pf, "Google", "Chrome", "Application", "chrome.exe"),
19
+ join(pf86, "Google", "Chrome", "Application", "chrome.exe"),
20
+ join(local, "Google", "Chrome", "Application", "chrome.exe"),
21
+ join(pf, "Chromium", "Application", "chrome.exe"),
23
22
  ]
24
23
  : platform() === "darwin"
25
24
  ? [
@@ -32,11 +31,27 @@ export function resolveChrome(env = process.env) {
32
31
  "/usr/bin/chromium",
33
32
  "/usr/bin/chromium-browser",
34
33
  ];
35
- for (const c of candidates) {
36
- if (c && existsSync(c))
37
- return c;
34
+ try {
35
+ return resolveExecutable([
36
+ "google-chrome",
37
+ "google-chrome-stable",
38
+ "chromium",
39
+ "chromium-browser",
40
+ "chrome",
41
+ "chrome.exe",
42
+ ], {
43
+ env,
44
+ envKey: "CHROME_PATH",
45
+ extraPaths,
46
+ notFoundMessage: "Chrome/Chromium not found — install Google Chrome or set CHROME_PATH",
47
+ });
48
+ }
49
+ catch (err) {
50
+ if (err instanceof ToolNotFoundError) {
51
+ throw new ChromeError(err.message);
52
+ }
53
+ throw err;
38
54
  }
39
- throw new ChromeError("Chrome/Chromium not found — install Google Chrome or set CHROME_PATH");
40
55
  }
41
56
  export async function screenshotHtml(options) {
42
57
  const chrome = options.chromePath ?? resolveChrome();
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import { defaultOutDir } from "../paths.js";
4
5
  import { resolveChrome, screenshotHtml, ChromeError } from "./chrome.js";
5
6
  export function buildComposeHtml(options) {
6
7
  const { beforeBase64, afterBase64, badge } = options;
@@ -32,7 +33,7 @@ function escapeHtml(s) {
32
33
  }
33
34
  export async function composeBeforeAfter(options) {
34
35
  const cwd = options.cwd ?? process.cwd();
35
- const outDir = resolve(cwd, options.outDir ?? ".farq");
36
+ const outDir = resolve(cwd, options.outDir ?? defaultOutDir(cwd));
36
37
  mkdirSync(outDir, { recursive: true });
37
38
  const before = readFileSync(options.beforePath);
38
39
  const after = readFileSync(options.afterPath);
@@ -5,11 +5,12 @@ import { gatherVisualFileContents } from "../git.js";
5
5
  import { decideGate } from "./gate.js";
6
6
  import { generateMockup } from "./mockup.js";
7
7
  import { generateDiagram } from "./diagram.js";
8
+ import { defaultOutDir } from "../paths.js";
8
9
  import { composeBeforeAfter, ChromeError } from "./compose.js";
9
10
  import { screenshotHtml, resolveChrome } from "./chrome.js";
10
11
  export async function runVisualPipeline(options) {
11
12
  const cwd = options.cwd ?? process.cwd();
12
- const outDir = resolve(cwd, options.outDir ?? ".farq");
13
+ const outDir = resolve(cwd, options.outDir ?? defaultOutDir(cwd));
13
14
  const log = options.log ?? (() => undefined);
14
15
  const vlog = (msg) => {
15
16
  if (options.verbose)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmedalbarghouti/farq",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals",
5
5
  "type": "module",
6
6
  "engines": {
@@ -36,6 +36,7 @@
36
36
  "dependencies": {
37
37
  "commander": "^13.0.0",
38
38
  "execa": "^9.0.0",
39
+ "picocolors": "^1.1.1",
39
40
  "zod": "^3.24.0"
40
41
  },
41
42
  "devDependencies": {