flowviant 0.28.9 → 0.29.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/bin/cli.mjs +10 -0
- package/bin/lib/claude.mjs +27 -15
- package/bin/lib/live.mjs +17 -9
- package/bin/lib/shot.mjs +218 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -101,6 +101,16 @@ if (process.argv[2] === 'clean') {
|
|
|
101
101
|
process.exit(0);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// `flowviant shot <url>` — capture a headless-browser screenshot of a running
|
|
105
|
+
// page. Build agents shell out to this to attach REAL visual evidence to the
|
|
106
|
+
// delivery card. Self-contained + graceful (no browser → exit 1, agent falls
|
|
107
|
+
// back to text evidence); needs no credential, so it runs before the auth gate.
|
|
108
|
+
if (process.argv[2] === 'shot') {
|
|
109
|
+
const { runShot } = await import('./lib/shot.mjs');
|
|
110
|
+
await runShot(process.argv.slice(3));
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
104
114
|
// `flowviant env <import|set|show>` — the CLI half of team env sync. Values
|
|
105
115
|
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
106
116
|
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -147,32 +147,44 @@ its frontmatter and title are part of the chapter and must comply):
|
|
|
147
147
|
Open every existing docs/ chapter and FIX any that violate these two rules on
|
|
148
148
|
EVERY run. The sidebar grouping + clean titles depend on it; it is not skippable.
|
|
149
149
|
|
|
150
|
-
Every
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
single
|
|
156
|
-
|
|
157
|
-
a
|
|
150
|
+
Every page declares its sidebar GROUP with a "category:" line in its frontmatter
|
|
151
|
+
— the group header it sits under, like the grouped left nav in HuggingFace docs.
|
|
152
|
+
The category may be TWO levels, "Top group / Sub-group", to add HuggingFace's
|
|
153
|
+
second nav tier: use the sub-level to break a LARGE top group into coherent
|
|
154
|
+
sub-groups (e.g. "Workspaces / Fundraising", "Workspaces / Finance & budget"); a
|
|
155
|
+
single level ("Reference") is fine for small groups. Aim for 3-6 top groups that
|
|
156
|
+
mirror the codebase's real divisions; a group OR sub-group holding a single page
|
|
157
|
+
is a smell — merge or regroup. Keep same-group pages CONTIGUOUS by filename number
|
|
158
|
+
so reading order also orders the nav. The "# Title" is a clean human name — NO
|
|
159
|
+
number prefix (ordering comes from the filename prefix).
|
|
160
|
+
|
|
161
|
+
Prefer MANY FOCUSED pages over a few giant chapters — HuggingFace granularity:
|
|
162
|
+
ONE page per coherent topic, not one page per whole subsystem. If a subsystem is
|
|
163
|
+
large, SPLIT it into several pages (its overview, its data model, its API, its
|
|
164
|
+
key flows), each its own docs/NN-page.md with its own category, so the left nav
|
|
165
|
+
is a fine-grained tree of pages and each page is focused enough to read in one
|
|
166
|
+
sitting. The in-page "## " sections are the right-hand on-this-page rail — the
|
|
167
|
+
left nav is pages, so when a chapter grows more than a handful of "## " sections,
|
|
168
|
+
that is the signal to split it into separate pages.
|
|
158
169
|
|
|
159
170
|
Fixed spine (flat docs/ files; numeric prefix = reading order):
|
|
160
171
|
- docs/00-start-here.md (category: "Getting started") — the landing page + MASTER
|
|
161
172
|
TABLE OF CONTENTS: what the product is (2-3 sentences); how to run it locally
|
|
162
173
|
(prerequisites, install, required env, dev server, tests); then a linked table
|
|
163
|
-
of contents of EVERY
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
of contents of EVERY page GROUPED BY CATEGORY, each with a one-line description;
|
|
175
|
+
then 2-3 role-based reading paths (e.g. "New to the backend: read Architecture,
|
|
176
|
+
then Agent fleet, then Data model").
|
|
166
177
|
- docs/01-architecture.md (category: "Getting started") — the system at a glance:
|
|
167
178
|
a Mermaid diagram (a fenced code block whose language is mermaid) of the major
|
|
168
179
|
components and how they connect, a component-responsibility table, the primary
|
|
169
|
-
request/data flows, and a link into the
|
|
170
|
-
- docs/
|
|
171
|
-
|
|
180
|
+
request/data flows, and a link into the page for each component.
|
|
181
|
+
- docs/NN-<page>.md — the subsystem PAGES: many focused pages (split large
|
|
182
|
+
subsystems into several), EACH with its own 1- or 2-level "category:" placing it
|
|
183
|
+
in the nav. Cover every significant part of the system.
|
|
172
184
|
- docs/90-decisions.md (category: "Reference") — notable design decisions, each as
|
|
173
185
|
context, decision, why, and consequences.
|
|
174
186
|
- docs/91-glossary.md (category: "Reference") — the project's terms of art,
|
|
175
|
-
alphabetized, each linking to the
|
|
187
|
+
alphabetized, each linking to the page that defines it.
|
|
176
188
|
|
|
177
189
|
EVERY chapter follows this exact anatomy, in order:
|
|
178
190
|
1. YAML frontmatter: a "category:" group header (see the spine) AND a "files:"
|
package/bin/lib/live.mjs
CHANGED
|
@@ -112,6 +112,7 @@ const SAFE_TOOLS = [
|
|
|
112
112
|
'Bash(gh:*)',
|
|
113
113
|
'Bash(npm:*)',
|
|
114
114
|
'Bash(bun:*)',
|
|
115
|
+
'Bash(flowviant:*)', // `flowviant shot` — capture screenshot evidence
|
|
115
116
|
'mcp__flowviant',
|
|
116
117
|
];
|
|
117
118
|
|
|
@@ -131,14 +132,21 @@ tools. When you hit a decision only a human can make, call report_blocker (with
|
|
|
131
132
|
options when you can) and then STOP your turn — do not spin or guess; you will be
|
|
132
133
|
resumed with the answer. As you satisfy each "done when" criterion, call
|
|
133
134
|
attach_evidence for it — proof the reviewer can SEE without running anything.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
This IS your handover, so make it tangible; match the evidence to what you built:
|
|
136
|
+
• UI / any visible screen → attach a real SCREENSHOT. Start the app's dev server
|
|
137
|
+
in your worktree, then capture it headlessly with
|
|
138
|
+
\`flowviant shot http://localhost:<PORT>/<route> --out shot.png\` (it finds a
|
|
139
|
+
browser for you and never needs a display), and attach_evidence with kind
|
|
140
|
+
"screenshot" and the file's base64 (\`base64 -w0 shot.png\`). Shoot EVERY key
|
|
141
|
+
screen you changed. If \`flowviant shot\` reports that no browser is available,
|
|
142
|
+
do NOT block — fall back to the text evidence below.
|
|
143
|
+
• backend / API work → a request/response capture or a data sample showing the
|
|
144
|
+
write (kind "request_response" or "sample").
|
|
145
|
+
• a multi-step FLOW (login, signup, checkout): one screenshot does NOT prove it
|
|
146
|
+
works — write an e2e/integration test that DRIVES the flow (fill form → submit
|
|
147
|
+
→ assert the post-success state), attach its test_output, AND screenshot the
|
|
148
|
+
end state. Never let a single static screenshot stand in for a flow.
|
|
149
|
+
When the work is done: open ONE draft PR (git push +
|
|
142
150
|
gh pr create --draft), call attach_pr, then call complete with a plain-language
|
|
143
151
|
summary of what you built AND a criteria self-report (index into the brief's
|
|
144
152
|
"done when" list + met true/false + a short note per item). That summary +
|
|
@@ -168,7 +176,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
168
176
|
? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
|
|
169
177
|
: []),
|
|
170
178
|
``,
|
|
171
|
-
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it (test output
|
|
179
|
+
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it — a real screenshot for UI (run the dev server, then \`flowviant shot <url> --out shot.png\`), or test output / a request-response / a data sample for backend, so it's reviewable without running anything; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
|
|
172
180
|
].join('\n');
|
|
173
181
|
}
|
|
174
182
|
|
package/bin/lib/shot.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flowviant shot <url>` — capture a headless-browser screenshot of a running
|
|
3
|
+
* page, so a build agent can attach REAL visual evidence to its delivery card
|
|
4
|
+
* (not just the ephemeral live preview). This is the primitive the daemon agent
|
|
5
|
+
* shells out to; it wraps the two fiddly parts — finding a browser across the
|
|
6
|
+
* user's environment, and driving Chrome's headless `--screenshot` — so the
|
|
7
|
+
* agent doesn't have to guess.
|
|
8
|
+
*
|
|
9
|
+
* Design mirrors the preview/cloudflared path: zero-config where possible, and
|
|
10
|
+
* NEVER a hard failure. No browser found / render crashes → a clear one-line
|
|
11
|
+
* hint on stderr + a non-zero exit, and the agent falls back to text evidence
|
|
12
|
+
* (test output, request/response, sample). It must never block a delivery.
|
|
13
|
+
*
|
|
14
|
+
* Environment coverage (the "what about WSL / a Linux VM?" cases):
|
|
15
|
+
* - Linux: PATH + conventional install paths for chrome/chromium/edge/brave.
|
|
16
|
+
* - macOS / Windows: the standard app locations.
|
|
17
|
+
* - WSL with no Linux browser: falls back to Windows Chrome/Edge via /mnt/c
|
|
18
|
+
* interop, translating paths with `wslpath` (WSL2 forwards localhost, so a
|
|
19
|
+
* Windows browser can still reach the dev server running in WSL).
|
|
20
|
+
* - A bare VM with no browser AND no system libs: probing/render fails →
|
|
21
|
+
* graceful text-evidence fallback with an `apt install chromium` hint.
|
|
22
|
+
* - FLOWVIANT_CHROME / CHROME_PATH override wins over all discovery.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
26
|
+
import { existsSync, statSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { tmpdir, platform } from 'node:os';
|
|
29
|
+
|
|
30
|
+
// ── Browser discovery ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const LINUX_BINS = [
|
|
33
|
+
'google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser',
|
|
34
|
+
'chrome', 'brave-browser', 'microsoft-edge', 'microsoft-edge-stable',
|
|
35
|
+
];
|
|
36
|
+
const LINUX_PATHS = [
|
|
37
|
+
'/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium',
|
|
38
|
+
'/usr/bin/chromium-browser', '/snap/bin/chromium', '/usr/bin/brave-browser',
|
|
39
|
+
'/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable',
|
|
40
|
+
];
|
|
41
|
+
const MAC_PATHS = [
|
|
42
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
43
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
44
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
45
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
46
|
+
];
|
|
47
|
+
const WIN_PATHS = [
|
|
48
|
+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
49
|
+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
50
|
+
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
51
|
+
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
|
|
52
|
+
];
|
|
53
|
+
// WSL interop: the same Windows installs, seen through the /mnt/c mount.
|
|
54
|
+
const WSL_WIN_PATHS = WIN_PATHS.map((p) => `/mnt/c/${p.slice(3)}`);
|
|
55
|
+
|
|
56
|
+
function which(name) {
|
|
57
|
+
try {
|
|
58
|
+
const p = execFileSync('which', [name], { encoding: 'utf8' }).trim();
|
|
59
|
+
return p || null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isWSL() {
|
|
66
|
+
if (process.env.WSL_DISTRO_NAME) return true;
|
|
67
|
+
try {
|
|
68
|
+
return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Locate a usable browser, or null. `viaWindows` means a Windows .exe reached
|
|
75
|
+
* through WSL interop — its file-path args must be translated with wslpath. */
|
|
76
|
+
export function resolveBrowser() {
|
|
77
|
+
const override = process.env.FLOWVIANT_CHROME || process.env.CHROME_PATH;
|
|
78
|
+
if (override && existsSync(override)) return { bin: override, viaWindows: false };
|
|
79
|
+
|
|
80
|
+
const os = platform();
|
|
81
|
+
if (os === 'darwin') {
|
|
82
|
+
for (const p of MAC_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (os === 'win32') {
|
|
86
|
+
for (const p of WIN_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// linux
|
|
90
|
+
for (const name of LINUX_BINS) {
|
|
91
|
+
const p = which(name);
|
|
92
|
+
if (p) return { bin: p, viaWindows: false };
|
|
93
|
+
}
|
|
94
|
+
for (const p of LINUX_PATHS) if (existsSync(p)) return { bin: p, viaWindows: false };
|
|
95
|
+
// WSL with no Linux browser — reach the Windows one.
|
|
96
|
+
if (isWSL()) {
|
|
97
|
+
for (const p of WSL_WIN_PATHS) if (existsSync(p)) return { bin: p, viaWindows: true };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Capture ─────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function toWinPath(p) {
|
|
105
|
+
return execFileSync('wslpath', ['-w', p], { encoding: 'utf8' }).trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runChrome(browser, { url, out, width, height, headlessFlag, timeoutMs }) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-shot-'));
|
|
111
|
+
const cleanup = () => { try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
112
|
+
|
|
113
|
+
// A Windows .exe can't read a WSL path — translate the file args it touches.
|
|
114
|
+
let outArg = out;
|
|
115
|
+
let udArg = userDataDir;
|
|
116
|
+
if (browser.viaWindows) {
|
|
117
|
+
try {
|
|
118
|
+
outArg = toWinPath(out);
|
|
119
|
+
udArg = toWinPath(userDataDir);
|
|
120
|
+
} catch {
|
|
121
|
+
cleanup();
|
|
122
|
+
return resolve({ ok: false, reason: 'wslpath', message: 'wslpath unavailable — cannot use Windows Chrome from WSL.' });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const args = [
|
|
127
|
+
headlessFlag,
|
|
128
|
+
'--disable-gpu',
|
|
129
|
+
'--no-sandbox', // required as root / in many VMs + containers
|
|
130
|
+
'--disable-dev-shm-usage', // small /dev/shm in containers/VMs crashes Chrome otherwise
|
|
131
|
+
'--hide-scrollbars',
|
|
132
|
+
'--force-color-profile=srgb',
|
|
133
|
+
`--user-data-dir=${udArg}`,
|
|
134
|
+
`--window-size=${width},${height}`,
|
|
135
|
+
'--virtual-time-budget=5000', // let fonts/JS settle before capture (SPAs)
|
|
136
|
+
`--screenshot=${outArg}`,
|
|
137
|
+
url,
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
let err = '';
|
|
141
|
+
let child;
|
|
142
|
+
try {
|
|
143
|
+
child = spawn(browser.bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
cleanup();
|
|
146
|
+
return resolve({ ok: false, reason: 'spawn', message: e.message });
|
|
147
|
+
}
|
|
148
|
+
child.stderr?.on('data', (d) => { err += d.toString(); });
|
|
149
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, timeoutMs);
|
|
150
|
+
child.on('error', (e) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
cleanup();
|
|
153
|
+
resolve({ ok: false, reason: 'spawn', message: e.message });
|
|
154
|
+
});
|
|
155
|
+
child.on('close', (code) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
cleanup();
|
|
158
|
+
if (existsSync(out) && statSync(out).size > 0) return resolve({ ok: true, path: out });
|
|
159
|
+
const tail = err.trim().split('\n').slice(-2).join(' ');
|
|
160
|
+
resolve({ ok: false, reason: 'render', message: `Chrome exited (code ${code}) without an image.${tail ? ' ' + tail : ''}` });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Capture `url` to a PNG at `out`. Tries new headless, then classic headless
|
|
166
|
+
* (older Chromium). Resolves { ok, path } or { ok:false, reason, message }. */
|
|
167
|
+
export async function captureScreenshot({ url, out, width = 1440, height = 900, timeoutMs = 60_000 }) {
|
|
168
|
+
const browser = resolveBrowser();
|
|
169
|
+
if (!browser) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
reason: 'no-browser',
|
|
173
|
+
message: 'No Chrome/Chromium/Edge found. Install one (Linux: `sudo apt install chromium`) to capture screenshot evidence.',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
let r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless=new', timeoutMs });
|
|
177
|
+
if (!r.ok && r.reason === 'render') {
|
|
178
|
+
// Older Chromium rejects `--headless=new` — retry with classic headless.
|
|
179
|
+
r = await runChrome(browser, { url, out, width, height, headlessFlag: '--headless', timeoutMs });
|
|
180
|
+
}
|
|
181
|
+
return r;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function getOpt(argv, name) {
|
|
187
|
+
const i = argv.indexOf(name);
|
|
188
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `flowviant shot <url> [--out file.png] [--width N] [--height N] [--base64]`
|
|
192
|
+
* On success prints the PNG path (or its base64 with --base64) to stdout and
|
|
193
|
+
* exits 0. On any failure: a hint on stderr, exit 1 (graceful — the agent then
|
|
194
|
+
* attaches text evidence instead). Usage error exits 2. */
|
|
195
|
+
export async function runShot(argv) {
|
|
196
|
+
const url = argv.find((a) => !a.startsWith('--') && /^(https?|file|data):/i.test(a));
|
|
197
|
+
if (!url) {
|
|
198
|
+
console.error('usage: flowviant shot <url> [--out file.png] [--width 1440] [--height 900] [--base64]');
|
|
199
|
+
console.error(' url: a running page (http://localhost:5173/…), a built file (file://…), or a data: URL');
|
|
200
|
+
process.exit(2);
|
|
201
|
+
}
|
|
202
|
+
const out = getOpt(argv, '--out') || join(process.cwd(), `flowviant-shot-${Date.now()}.png`);
|
|
203
|
+
const width = Number(getOpt(argv, '--width')) || 1440;
|
|
204
|
+
const height = Number(getOpt(argv, '--height')) || 900;
|
|
205
|
+
const wantBase64 = argv.includes('--base64');
|
|
206
|
+
|
|
207
|
+
const r = await captureScreenshot({ url, out, width, height });
|
|
208
|
+
if (!r.ok) {
|
|
209
|
+
console.error(`flowviant shot: ${r.message}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
if (wantBase64) {
|
|
213
|
+
process.stdout.write(readFileSync(out).toString('base64'));
|
|
214
|
+
} else {
|
|
215
|
+
console.log(r.path);
|
|
216
|
+
}
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|