phyll 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // Phyll static scan: finds AI tells in the source and maps routes, forms and modals.
3
+ //
4
+ // node scan.mjs [dir] [--out scan.json] [--format json|text] [--config path]
5
+ //
6
+ // With --out the JSON goes to the file and a one-line summary to stdout.
7
+ // Without it, JSON (default) or a text summary goes to stdout.
8
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { parseArgs } from "node:util";
12
+ import { fail, isMain } from "./lib/cli.mjs";
13
+ import { configPath, readConfigFile } from "./lib/config.mjs";
14
+ import { runTells } from "./lib/detectors.mjs";
15
+ import { walk } from "./lib/files.mjs";
16
+ import { computeIndex, kindOf, staticEntries } from "./lib/score.mjs";
17
+ import { analyzeStructure } from "./lib/structure.mjs";
18
+ import { NAME, VERSION } from "./lib/version.mjs";
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ export const TELLS_PATH = join(HERE, "..", "data", "tells.json");
22
+
23
+ export function loadTells(path = TELLS_PATH) {
24
+ return JSON.parse(readFileSync(path, "utf8")).tells;
25
+ }
26
+
27
+ // The scanned folder relative to where the scan runs, so reports do not carry local paths
28
+ // such as a home folder. Folders outside the working directory keep only their name.
29
+ export function displayRoot(root, cwd = process.cwd()) {
30
+ const rel = relative(cwd, root).split(sep).join("/");
31
+ if (rel === "") return ".";
32
+ if (rel.startsWith("..") || isAbsolute(rel)) return basename(root);
33
+ return rel;
34
+ }
35
+
36
+ export function scan(root, { ignore = [], tells = loadTells() } = {}) {
37
+ const { files, ignored } = walk(root, { ignore });
38
+ const cache = new Map();
39
+ const readText = (file) => {
40
+ if (!cache.has(file.abs)) cache.set(file.abs, readFileSync(file.abs, "utf8"));
41
+ return cache.get(file.abs);
42
+ };
43
+
44
+ const structure = analyzeStructure(root, files, readText);
45
+ const results = new Map(runTells(tells, files, readText, structure).map((r) => [r.id, r]));
46
+
47
+ const scanned = tells
48
+ .filter((t) => t.detection !== "dynamic")
49
+ .map((t) => ({
50
+ id: t.id,
51
+ name: t.name,
52
+ dimension: t.dimension,
53
+ kind: kindOf(t),
54
+ detection: t.detection,
55
+ weight: t.weight,
56
+ cap: t.cap,
57
+ hits: results.get(t.id)?.hits ?? 0,
58
+ locations: results.get(t.id)?.locations ?? [],
59
+ }));
60
+
61
+ const tellsById = new Map(tells.map((t) => [t.id, t]));
62
+ const entries = staticEntries(tells, scanned);
63
+ return {
64
+ tool: { name: NAME, version: VERSION },
65
+ root: displayRoot(root),
66
+ createdAt: new Date().toISOString(),
67
+ files: { scanned: files.length, ignored },
68
+ structure,
69
+ tells: scanned,
70
+ staticIndex: computeIndex(tellsById, entries, "function"),
71
+ styleIndex: computeIndex(tellsById, entries, "style"),
72
+ };
73
+ }
74
+
75
+ export function formatText(result) {
76
+ const lines = [];
77
+ lines.push(`Phyll scan of ${result.root}`);
78
+ lines.push(
79
+ `Files: ${result.files.scanned} scanned, ${result.files.ignored} ignored. Framework: ${result.structure.framework}. Theme: ${result.structure.theme ?? "unknown"}.`,
80
+ );
81
+ lines.push(`Static AI tell index: ${result.staticIndex ?? "n/a"}/100 (lower is better)`);
82
+ lines.push("");
83
+
84
+ const found = result.tells
85
+ .filter((t) => t.hits > 0)
86
+ .sort((a, b) => b.weight - a.weight || b.hits - a.hits);
87
+ const functionTells = found.filter((t) => (t.kind ?? "function") === "function");
88
+ const styleTells = found.filter((t) => t.kind === "style");
89
+ const width = Math.min(48, Math.max(10, ...found.map((t) => t.name.length)));
90
+ const row = (t) => {
91
+ const where = t.locations[0] ? `${t.locations[0].file}:${t.locations[0].line}` : "";
92
+ const name = t.name.length > width ? t.name.slice(0, width - 1) + "." : t.name.padEnd(width);
93
+ return ` ${t.id} ${name} ${String(t.hits).padStart(3)} hits ${where}`;
94
+ };
95
+
96
+ if (functionTells.length === 0) lines.push("No tells that get in the way of use were found in the source.");
97
+ else lines.push("Tells that get in the way of use:", ...functionTells.map(row));
98
+ lines.push("");
99
+ if (styleTells.length) {
100
+ lines.push(
101
+ `Style notes, left as they are (style index ${result.styleIndex ?? "n/a"}/100):`,
102
+ ...styleTells.map(row),
103
+ "",
104
+ );
105
+ }
106
+
107
+ const { routes, forms, modals, notes } = result.structure;
108
+ lines.push(routes.length ? `Routes: ${routes.map((r) => r.path).join(", ")}` : "Routes: none found");
109
+ const longForms = forms.filter((f) => f.fields >= 7);
110
+ if (forms.length) {
111
+ const deferred = forms.reduce((sum, f) => sum + (f.deferred ?? 0), 0);
112
+ lines.push(
113
+ `Forms: ${forms.length}` +
114
+ (longForms.length ? `, longest ${Math.max(...forms.map((f) => f.fields))} fields` : "") +
115
+ (deferred ? `, plus ${deferred} optional behind a closed <details>` : ""),
116
+ );
117
+ }
118
+ if (modals.count) lines.push(`Modals: ${modals.count}`);
119
+ for (const note of notes) lines.push(`Note: ${note}`);
120
+ return lines.join("\n") + "\n";
121
+ }
122
+
123
+ function main() {
124
+ let args;
125
+ try {
126
+ args = parseArgs({
127
+ allowPositionals: true,
128
+ options: {
129
+ out: { type: "string" },
130
+ format: { type: "string", default: "json" },
131
+ config: { type: "string" },
132
+ help: { type: "boolean", short: "h" },
133
+ },
134
+ });
135
+ } catch (error) {
136
+ fail(error.message, 2);
137
+ }
138
+ const { values, positionals } = args;
139
+ if (values.help) {
140
+ process.stdout.write("Usage: node scan.mjs [dir] [--out scan.json] [--format json|text] [--config path]\n");
141
+ return;
142
+ }
143
+
144
+ const root = resolve(positionals[0] ?? ".");
145
+ let isDir = false;
146
+ try {
147
+ isDir = statSync(root).isDirectory();
148
+ } catch {
149
+ isDir = false;
150
+ }
151
+ if (!isDir) fail(`${root} is not a folder`, 2);
152
+
153
+ let config;
154
+ try {
155
+ config = readConfigFile(values.config ? resolve(values.config) : configPath(root));
156
+ } catch (error) {
157
+ fail(error.message, 2);
158
+ }
159
+
160
+ const result = scan(root, { ignore: Array.isArray(config.ignore) ? config.ignore : [] });
161
+ const json = JSON.stringify(result, null, 2) + "\n";
162
+
163
+ if (values.out) {
164
+ const out = resolve(values.out);
165
+ mkdirSync(dirname(out), { recursive: true });
166
+ writeFileSync(out, json);
167
+ const found = result.tells.filter((t) => t.hits > 0).length;
168
+ if (values.format === "text") process.stdout.write(formatText(result));
169
+ process.stdout.write(`Wrote ${out}: static index ${result.staticIndex ?? "n/a"}, ${found} tells found.\n`);
170
+ return;
171
+ }
172
+ process.stdout.write(values.format === "text" ? formatText(result) : json);
173
+ }
174
+
175
+ if (isMain(import.meta.url)) main();
@@ -0,0 +1,317 @@
1
+ // The browser the review agent walks the app with. It stays on the reviewed site, accepts
2
+ // dialogs and reports what they said, saves screenshots and probe results in the report folder,
3
+ // and keeps a log of every action in actions.json as part of the evidence.
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export const SIZES = Object.freeze({
8
+ desktop: { viewport: { width: 1440, height: 900 } },
9
+ mobile: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true },
10
+ });
11
+
12
+ const MAX_TREE = 12000;
13
+ const MAX_SHOT_HEIGHT = 5000;
14
+ const clip = (s, n = 300) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
15
+
16
+ export function sameSite(target, base) {
17
+ try {
18
+ return new URL(target, base).origin === new URL(base).origin;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export function fileSlug(name) {
25
+ const slug = String(name ?? "")
26
+ .toLowerCase()
27
+ .normalize("NFD")
28
+ .replace(/[̀-ͯ]/g, "")
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-+|-+$/g, "")
31
+ .slice(0, 60);
32
+ return slug || "screen";
33
+ }
34
+
35
+ // A few lines the agent can read at a glance; the full probe result is saved as JSON.
36
+ export function summarizeProbe(p) {
37
+ const lines = [];
38
+ if (p?.page) lines.push(`Page height ${p.page.height}px${p.page.horizontalOverflow ? ", and it scrolls sideways" : ""}.`);
39
+ if (p?.contrast) {
40
+ const samples = (p.contrast.samples ?? [])
41
+ .slice(0, 3)
42
+ .map((s) => `"${clip(s.text, 40)}" at ${s.ratio}:1`)
43
+ .join("; ");
44
+ lines.push(`Contrast: ${p.contrast.failures} of ${p.contrast.checked} texts below the minimum${samples ? ` (${samples})` : ""}.`);
45
+ }
46
+ if (p?.actions) {
47
+ const a = p.actions;
48
+ lines.push(
49
+ `Actions: ${a.total} in total, ${a.primaryInFirstView} primary in the first view, ${a.iconOnlyUnnamed} icon-only without a name, ` +
50
+ `${a.smallTargets} smaller than 24px, ${a.deadLinks} dead links, ${a.hiddenUntilHover} shown only on hover.`,
51
+ );
52
+ }
53
+ if (p?.forms?.length) lines.push(`Forms: ${p.forms.map((f) => `${f.fields} fields, ${f.unlabeled} without a label`).join("; ")}.`);
54
+ if (p?.looseFields) lines.push(`${p.looseFields} fields sit outside any form.`);
55
+ if (p?.decor) lines.push(`Decoration: ${Object.entries(p.decor).map(([k, v]) => `${k} ${v}`).join(", ")}.`);
56
+ if (p?.errors?.length) lines.push(`The probe could not measure: ${p.errors.slice(0, 3).map((e) => clip(e, 80)).join("; ")}.`);
57
+ return lines.join("\n");
58
+ }
59
+
60
+ export class BrowserSession {
61
+ // allowRequest(url) decides every request the pages make. Phyll Cloud passes one that refuses
62
+ // private addresses; the command line tool, reviewing your own app, passes none.
63
+ constructor({ playwright, baseUrl, out, probeSource, headless = true, allowRequest = null }) {
64
+ this.playwright = playwright;
65
+ this.baseUrl = new URL(baseUrl).href;
66
+ this.out = out;
67
+ this.probeSource = probeSource;
68
+ this.headless = headless;
69
+ this.allowRequest = allowRequest;
70
+ this.size = "desktop";
71
+ this.log = [];
72
+ this.dialogs = [];
73
+ this.errors = [];
74
+ this.popups = [];
75
+ this.blocked = [];
76
+ this.lastStatus = null;
77
+ }
78
+
79
+ async start(size = "desktop") {
80
+ this.browser ??= await this.playwright.chromium.launch({ headless: this.headless });
81
+ await this.#newContext(size);
82
+ return this;
83
+ }
84
+
85
+ async #newContext(size) {
86
+ await this.context?.close().catch(() => {});
87
+ this.context = await this.browser.newContext({ ...SIZES[size], acceptDownloads: false });
88
+ if (this.allowRequest) {
89
+ await this.context.route("**/*", async (route) => {
90
+ const url = route.request().url();
91
+ if (await this.allowRequest(url)) return route.continue();
92
+ this.blocked.push(clip(url, 160));
93
+ return route.abort("blockedbyclient");
94
+ });
95
+ // WebSockets do not pass through route(), so they get the same check here.
96
+ await this.context.routeWebSocket(/.*/, async (ws) => {
97
+ if (await this.allowRequest(ws.url())) return ws.connectToServer();
98
+ this.blocked.push(clip(ws.url(), 160));
99
+ return ws.close({ code: 1008, reason: "Blocked by Phyll" });
100
+ });
101
+ }
102
+ this.size = size;
103
+ this.page = await this.context.newPage();
104
+ this.page.on("dialog", async (dialog) => {
105
+ this.dialogs.push(`${dialog.type()} "${clip(dialog.message(), 160)}"`);
106
+ await dialog.accept().catch(() => {});
107
+ });
108
+ this.page.on("console", (m) => {
109
+ if (m.type() === "error") this.errors.push(clip(m.text(), 200));
110
+ });
111
+ this.page.on("pageerror", (e) => this.errors.push(clip(e.message, 200)));
112
+ // New tabs, such as links with target=_blank, are closed and reported instead of followed.
113
+ this.context.on("page", (p) => {
114
+ this.popups.push(clip(p.url(), 160));
115
+ p.close().catch(() => {});
116
+ });
117
+ }
118
+
119
+ #path() {
120
+ try {
121
+ const u = new URL(this.page.url());
122
+ return u.pathname + u.search + u.hash;
123
+ } catch {
124
+ return this.page.url();
125
+ }
126
+ }
127
+
128
+ #record(action, detail = "") {
129
+ this.log.push({ at: new Date().toISOString(), action, detail, path: this.#path(), viewport: this.size });
130
+ }
131
+
132
+ #state(prefix) {
133
+ const parts = [prefix, `Now on ${this.#path()} at ${this.size} size.`];
134
+ if (this.dialogs.length) parts.push(`Dialogs shown and accepted: ${this.dialogs.splice(0).join(" | ")}.`);
135
+ if (this.popups.length) parts.push(`New tabs opened and closed: ${this.popups.splice(0).join(" | ")}.`);
136
+ if (this.blocked.length) parts.push(`Requests to private addresses were blocked: ${this.blocked.splice(0).slice(0, 3).join(" | ")}.`);
137
+ if (this.errors.length) parts.push(`JavaScript errors: ${this.errors.splice(0).join(" | ")}.`);
138
+ return parts.join(" ");
139
+ }
140
+
141
+ async #goto(url) {
142
+ let response = null;
143
+ try {
144
+ response = await this.page.goto(url, { waitUntil: "networkidle", timeout: 20000 });
145
+ } catch {
146
+ response = await this.page.goto(url, { waitUntil: "load", timeout: 20000 });
147
+ }
148
+ this.lastStatus = response?.status() ?? null;
149
+ }
150
+
151
+ async #settle() {
152
+ await this.page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
153
+ await this.page.waitForTimeout(150);
154
+ }
155
+
156
+ async #after(label) {
157
+ await this.#settle();
158
+ let note = "";
159
+ if (!sameSite(this.page.url(), this.baseUrl)) {
160
+ const left = clip(this.page.url(), 160);
161
+ await this.page.goBack({ timeout: 10000 }).catch(() => {});
162
+ note = ` It led outside the app, to ${left}, so the browser went back.`;
163
+ }
164
+ return this.#state(`${label}.${note}`);
165
+ }
166
+
167
+ async open(target) {
168
+ const url = new URL(target, this.baseUrl).href;
169
+ if (!sameSite(url, this.baseUrl)) throw new Error(`only pages on ${new URL(this.baseUrl).origin} can be opened`);
170
+ if (this.allowRequest && !(await this.allowRequest(url))) throw new Error("that address is not public, so it cannot be opened");
171
+ await this.#goto(url);
172
+ this.#record("open", url);
173
+ const failed = this.lastStatus >= 400 ? ` and the server answered ${this.lastStatus}` : "";
174
+ return this.#state(`Opened ${this.#path()}${failed}`);
175
+ }
176
+
177
+ async snapshot() {
178
+ const title = await this.page.title().catch(() => "");
179
+ let tree = await this.page
180
+ .locator("body")
181
+ .ariaSnapshot({ timeout: 5000 })
182
+ .catch(() => "");
183
+ if (tree.length > MAX_TREE) tree = `${tree.slice(0, MAX_TREE)}\n[the rest of the page was cut]`;
184
+ this.#record("snapshot");
185
+ return `${this.#state(`Page titled "${clip(title, 120)}".`)}\n\n${tree}`;
186
+ }
187
+
188
+ async click({ role, name, text, exact = false, nth = 0 } = {}) {
189
+ let target = role ? this.page.getByRole(role, { name, exact }) : this.page.getByText(text ?? name ?? "", { exact });
190
+ let what = role ? `${role} "${name ?? ""}"` : `"${text ?? name}"`;
191
+ let count = await target.count();
192
+ if (!count && role && name) {
193
+ target = this.page.getByText(name, { exact });
194
+ what = `"${name}"`;
195
+ count = await target.count();
196
+ }
197
+ if (!count) throw new Error(`nothing on the page matches ${what}; take a snapshot to see what is there`);
198
+ await target.nth(Math.min(nth, count - 1)).click({ timeout: 5000 });
199
+ this.#record("click", what);
200
+ return this.#after(`Clicked ${what}${count > 1 ? `, the ${nth + 1}. of ${count} matches` : ""}`);
201
+ }
202
+
203
+ async fill({ label, placeholder, value } = {}) {
204
+ let target = label ? this.page.getByLabel(label, { exact: false }) : this.page.getByPlaceholder(placeholder ?? "");
205
+ if (label && !(await target.count())) target = this.page.getByPlaceholder(label);
206
+ if (!(await target.count())) throw new Error(`no field is labeled "${label ?? placeholder}"`);
207
+ await target.first().fill(String(value ?? ""), { timeout: 5000 });
208
+ this.#record("fill", label ?? placeholder);
209
+ return this.#after(`Typed into "${label ?? placeholder}"`);
210
+ }
211
+
212
+ async select({ label, option } = {}) {
213
+ const target = this.page.getByLabel(label, { exact: false });
214
+ if (!(await target.count())) throw new Error(`no list is labeled "${label}"`);
215
+ await target.first().selectOption({ label: option }, { timeout: 5000 });
216
+ this.#record("select", `${label}: ${option}`);
217
+ return this.#after(`Chose "${option}" in "${label}"`);
218
+ }
219
+
220
+ async check({ label, checked = true } = {}) {
221
+ const target = this.page.getByLabel(label, { exact: false }).first();
222
+ try {
223
+ if (checked) await target.check({ timeout: 3000 });
224
+ else await target.uncheck({ timeout: 3000 });
225
+ } catch {
226
+ // Choice chips often hide the real input; clicking the visible text works like a person would.
227
+ await this.page.getByText(label, { exact: true }).first().click({ timeout: 3000 });
228
+ }
229
+ this.#record(checked ? "check" : "uncheck", label);
230
+ return this.#after(`${checked ? "Checked" : "Unchecked"} "${label}"`);
231
+ }
232
+
233
+ async press({ key } = {}) {
234
+ await this.page.keyboard.press(key);
235
+ const focus = await this.page.evaluate(() => {
236
+ const el = document.activeElement;
237
+ if (!el || el === document.body) return "nothing";
238
+ const name = el.getAttribute("aria-label") || el.innerText || el.value || el.getAttribute("placeholder") || "";
239
+ return `${el.tagName.toLowerCase()} "${String(name).replace(/\s+/g, " ").trim().slice(0, 60)}"`;
240
+ });
241
+ this.#record("press", key);
242
+ return this.#after(`Pressed ${key}; focus is on ${focus}`);
243
+ }
244
+
245
+ async back() {
246
+ await this.page.goBack({ timeout: 10000 }).catch(() => {});
247
+ this.#record("back");
248
+ return this.#after("Went back");
249
+ }
250
+
251
+ async screenshot({ name, fullPage = false } = {}) {
252
+ const file = `screens/${this.size}-${fileSlug(name)}.png`;
253
+ mkdirSync(join(this.out, "screens"), { recursive: true });
254
+ const options = { path: join(this.out, file), fullPage };
255
+ if (fullPage) {
256
+ const height = await this.page.evaluate(() => document.documentElement.scrollHeight);
257
+ if (height > MAX_SHOT_HEIGHT) options.clip = { x: 0, y: 0, width: SIZES[this.size].viewport.width, height: MAX_SHOT_HEIGHT };
258
+ }
259
+ const saved = await this.page.screenshot(options);
260
+ // The agent looks at what is on screen; a full-page file can be too tall to read as one image.
261
+ const forAgent = fullPage ? await this.page.screenshot() : saved;
262
+ this.#record("screenshot", file);
263
+ return { path: file, data: forAgent.toString("base64") };
264
+ }
265
+
266
+ async probe({ name } = {}) {
267
+ const data = await this.page.evaluate(this.probeSource);
268
+ const file = `probe/${this.size}-${fileSlug(name)}.json`;
269
+ mkdirSync(join(this.out, "probe"), { recursive: true });
270
+ writeFileSync(join(this.out, file), JSON.stringify(data, null, 2) + "\n");
271
+ this.#record("probe", file);
272
+ return { path: file, summary: summarizeProbe(data) };
273
+ }
274
+
275
+ async resize({ size } = {}) {
276
+ if (!SIZES[size]) throw new Error(`the size must be desktop or mobile`);
277
+ const url = this.page?.url();
278
+ await this.#newContext(size);
279
+ if (url && url !== "about:blank") await this.#goto(url);
280
+ this.#record("resize", size);
281
+ return this.#state(`Switched to the ${size} size. The page loaded again, so anything kept only in memory was reset`);
282
+ }
283
+
284
+ async close() {
285
+ try {
286
+ writeFileSync(join(this.out, "actions.json"), JSON.stringify(this.log, null, 2) + "\n");
287
+ } finally {
288
+ await this.browser?.close().catch(() => {});
289
+ }
290
+ }
291
+ }
292
+
293
+ // The first capture of every route at both sizes, done by a session instead of capture.mjs, so
294
+ // it goes through the session's request rules. Writes capture.json in the same shape.
295
+ export async function captureWithSession({ session, routes, out, slugs }) {
296
+ const pages = [];
297
+ for (const size of ["desktop", "mobile"]) {
298
+ await session.resize({ size });
299
+ for (const [i, route] of routes.entries()) {
300
+ const entry = { route, viewport: size, url: new URL(route, session.baseUrl).href, status: null };
301
+ try {
302
+ await session.open(route);
303
+ entry.status = session.lastStatus;
304
+ entry.screenshot = (await session.screenshot({ name: slugs[i] })).path;
305
+ entry.probe = (await session.probe({ name: slugs[i] })).path;
306
+ } catch (error) {
307
+ entry.error = clip(error.message, 300);
308
+ }
309
+ entry.consoleErrors = session.errors.splice(0);
310
+ pages.push(entry);
311
+ }
312
+ }
313
+ await session.resize({ size: "desktop" });
314
+ const manifest = { tool: { name: "phyll" }, createdAt: new Date().toISOString(), baseUrl: session.baseUrl, viewports: ["desktop", "mobile"], pages };
315
+ writeFileSync(join(out, "capture.json"), JSON.stringify(manifest, null, 2) + "\n");
316
+ return manifest;
317
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,165 @@
1
+ // phyll: the commands a person types. The review itself happens inside their agent, through
2
+ // the MCP server that `phyll mcp` runs and `phyll setup` registers.
3
+ import { parseArgs } from "node:util";
4
+ import { clearCredentials, credentialsPath, loadCredentials, saveCredentials } from "./credentials.mjs";
5
+ import { engineClient } from "./engine.mjs";
6
+ import { importSkill, VERSION } from "./paths.mjs";
7
+ import { ensureBrowser, mcpCommand, registerClaude, writeCodexConfig } from "./setup.mjs";
8
+
9
+ export const HELP = `Phyll ${VERSION}: UX review for apps built with AI, inside the agent you already use.
10
+
11
+ Get started:
12
+ npx phyll signup you@example.com Create a free account; the key is saved on this computer
13
+ npx phyll setup codex Connect Phyll to Codex (or: npx phyll setup claude)
14
+ Then ask your agent: review my app at http://localhost:3000
15
+
16
+ Commands:
17
+ signup <email> Create a free account, with free full reviews
18
+ login <key> Use an account you already have on this computer
19
+ setup <agent> Connect Phyll to codex or claude, and install the browser it uses
20
+ status Your plan and the reviews left
21
+ pro Subscribe to Phyll Pro
22
+ billing Change the card or cancel Phyll Pro
23
+ scan [folder] Scan the source for AI tells. Free, with no account
24
+ mcp Run the connector for your agent (setup registers it for you)
25
+ logout Forget the key on this computer
26
+
27
+ Options: --server <address> for signup and login, --lang <code> for signup, --format json for scan.
28
+
29
+ The AI work runs in your agent, on your own plan. Phyll's engine sends the method and keeps the reports.
30
+ `;
31
+
32
+ const guessLanguage = () => ((Intl.DateTimeFormat().resolvedOptions().locale ?? "").toLowerCase().startsWith("pt") ? "pt-BR" : "en");
33
+
34
+ function options(args, spec) {
35
+ return parseArgs({ args, allowPositionals: true, strict: true, options: spec });
36
+ }
37
+
38
+ export async function main(argv, io = {}) {
39
+ const out = io.stdout ?? process.stdout;
40
+ const err = io.stderr ?? process.stderr;
41
+ const env = io.env ?? process.env;
42
+ const cwd = io.cwd ?? process.cwd();
43
+ const fetchImpl = io.fetch ?? globalThis.fetch;
44
+ const write = (text) => out.write(text);
45
+ const fail = (text, code = 1) => {
46
+ err.write(`phyll: ${text}\n`);
47
+ return code;
48
+ };
49
+ const client = (server, key) => engineClient({ server, key, version: VERSION, fetchImpl });
50
+ const [command, ...rest] = argv;
51
+
52
+ try {
53
+ switch (command) {
54
+ case undefined:
55
+ case "help":
56
+ case "-h":
57
+ case "--help":
58
+ write(HELP);
59
+ return 0;
60
+ case "version":
61
+ case "-v":
62
+ case "--version":
63
+ write(`phyll ${VERSION}\n`);
64
+ return 0;
65
+
66
+ case "signup": {
67
+ const { values, positionals } = options(rest, { server: { type: "string" }, lang: { type: "string" } });
68
+ if (positionals.length !== 1) return fail("give your email, such as npx phyll signup you@example.com", 2);
69
+ const server = String(values.server ?? loadCredentials(env).server ?? "").replace(/\/+$/, "");
70
+ if (!server) return fail("give the address of the Phyll server with --server", 2);
71
+ const answer = await client(server, null).signup(positionals[0], values.lang ?? guessLanguage());
72
+ if (!answer.ok) return fail(answer.json.message ?? `Phyll answered ${answer.status}.`);
73
+ const path = saveCredentials({ server, key: answer.json.key }, env);
74
+ write(`${answer.json.message}\nYour key, shown only now: ${answer.json.key}\nIt is saved in ${path}.\n\nNext, connect Phyll to your agent:\n npx phyll setup codex\n npx phyll setup claude\n`);
75
+ return 0;
76
+ }
77
+
78
+ case "login": {
79
+ const { values, positionals } = options(rest, { server: { type: "string" } });
80
+ if (positionals.length !== 1) return fail("give your key, such as npx phyll login phyll_...", 2);
81
+ const server = String(values.server ?? loadCredentials(env).server ?? "").replace(/\/+$/, "");
82
+ if (!server) return fail("give the address of the Phyll server with --server", 2);
83
+ const answer = await client(server, positionals[0]).me();
84
+ if (!answer.ok) return fail(answer.json.message ?? `Phyll answered ${answer.status}.`);
85
+ saveCredentials({ server, key: positionals[0] }, env);
86
+ write(`Signed in as ${answer.json.email}, on the ${answer.json.plan === "pro" ? "Phyll Pro" : "free"} plan.\n`);
87
+ return 0;
88
+ }
89
+
90
+ case "logout":
91
+ clearCredentials(env);
92
+ write(`Removed ${credentialsPath(env)}.\n`);
93
+ return 0;
94
+
95
+ case "status": {
96
+ const { server, key } = loadCredentials(env);
97
+ if (!key) return fail("no account on this computer yet. Run npx phyll signup you@example.com");
98
+ const answer = await client(server, key).me();
99
+ if (!answer.ok) return fail(answer.json.message ?? `Phyll answered ${answer.status}.`);
100
+ const me = answer.json;
101
+ const sessions = me.sessions ?? {};
102
+ const plan =
103
+ me.plan === "pro"
104
+ ? `Phyll Pro. Reviews this month: ${sessions.used ?? 0}.`
105
+ : `Free. ${Math.max(0, (sessions.limit ?? 0) - (sessions.used ?? 0))} of ${sessions.limit} free reviews left.`;
106
+ write(`${me.email} on ${server}\n${plan}\n`);
107
+ return 0;
108
+ }
109
+
110
+ case "pro":
111
+ case "billing": {
112
+ const { server, key } = loadCredentials(env);
113
+ if (!key) return fail("no account on this computer yet. Run npx phyll signup you@example.com");
114
+ const answer = command === "pro" ? await client(server, key).checkout() : await client(server, key).portal();
115
+ if (!answer.ok) return fail(answer.json.message ?? `Phyll answered ${answer.status}.`);
116
+ write(`${command === "pro" ? "Subscribe to Phyll Pro here" : "Manage Phyll Pro here"}:\n${answer.json.url}\n`);
117
+ return 0;
118
+ }
119
+
120
+ case "setup": {
121
+ const agent = rest[0];
122
+ if (!["codex", "claude"].includes(agent)) return fail("say which agent to connect: npx phyll setup codex, or npx phyll setup claude", 2);
123
+ const command = io.mcpCommand ?? mcpCommand();
124
+ if (agent === "codex") {
125
+ const file = writeCodexConfig(command, env);
126
+ write(`Phyll is connected to Codex in ${file}. Restart Codex so it loads the connector.\n`);
127
+ } else {
128
+ const result = registerClaude(command, io.run);
129
+ write(
130
+ result.ok
131
+ ? "Phyll is connected to Claude Code. Start a new session so it loads the connector.\n"
132
+ : `Claude Code was not found on this computer. Run this where it is installed:\n ${result.manual}\n`,
133
+ );
134
+ }
135
+ if (!(await (io.ensureBrowser ?? ensureBrowser)({ write }))) return fail("the browser could not be installed. Run: npx playwright install chromium");
136
+ write(
137
+ loadCredentials(env).key
138
+ ? "Next, ask your agent to review your app, for example: review my app at http://localhost:3000\n"
139
+ : "Next, create your account: npx phyll signup you@example.com\n",
140
+ );
141
+ return 0;
142
+ }
143
+
144
+ case "scan": {
145
+ const { values, positionals } = options(rest, { format: { type: "string" } });
146
+ const { scan, formatText } = await importSkill("scripts/scan.mjs");
147
+ const result = scan(positionals[0] ?? cwd);
148
+ write(values.format === "json" ? `${JSON.stringify(result, null, 2)}\n` : formatText(result));
149
+ return 0;
150
+ }
151
+
152
+ case "mcp": {
153
+ const { runMcpServer } = await import("./mcp.mjs");
154
+ await runMcpServer({ env, cwd, fetchImpl });
155
+ return null;
156
+ }
157
+
158
+ default:
159
+ return fail(`there is no command ${command}. Run npx phyll --help`, 2);
160
+ }
161
+ } catch (error) {
162
+ if (String(error?.code ?? "").startsWith("ERR_PARSE_ARGS")) return fail(`${error.message}. Run npx phyll --help`, 2);
163
+ throw error;
164
+ }
165
+ }