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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/phyll.mjs +6 -0
- package/package.json +42 -0
- package/skill/data/tells.json +1641 -0
- package/skill/schema/config.schema.json +51 -0
- package/skill/schema/tells.schema.json +156 -0
- package/skill/scripts/capture.mjs +224 -0
- package/skill/scripts/lib/cli.mjs +16 -0
- package/skill/scripts/lib/config.mjs +19 -0
- package/skill/scripts/lib/detectors.mjs +301 -0
- package/skill/scripts/lib/files.mjs +134 -0
- package/skill/scripts/lib/score.mjs +51 -0
- package/skill/scripts/lib/structure.mjs +287 -0
- package/skill/scripts/lib/version.mjs +3 -0
- package/skill/scripts/probe.js +384 -0
- package/skill/scripts/scan.mjs +175 -0
- package/src/browser.mjs +317 -0
- package/src/cli.mjs +165 -0
- package/src/credentials.mjs +40 -0
- package/src/engine.mjs +40 -0
- package/src/mcp.mjs +145 -0
- package/src/paths.mjs +19 -0
- package/src/review.mjs +226 -0
- package/src/setup.mjs +69 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The Phyll key and server on this computer, in ~/.phyll/credentials.json. PHYLL_API_URL and
|
|
2
|
+
// PHYLL_API_KEY win over the file, and PHYLL_HOME moves the folder.
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// The address of Phyll's own server. --server or PHYLL_API_URL point the connector elsewhere.
|
|
8
|
+
export const DEFAULT_SERVER = "https://agentphyll.com";
|
|
9
|
+
|
|
10
|
+
export const phyllHome = (env = process.env) => env.PHYLL_HOME || join(homedir(), ".phyll");
|
|
11
|
+
export const credentialsPath = (env = process.env) => join(phyllHome(env), "credentials.json");
|
|
12
|
+
|
|
13
|
+
export function loadCredentials(env = process.env) {
|
|
14
|
+
let file = {};
|
|
15
|
+
try {
|
|
16
|
+
file = JSON.parse(readFileSync(credentialsPath(env), "utf8"));
|
|
17
|
+
} catch {
|
|
18
|
+
file = {};
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
server: String(env.PHYLL_API_URL || file.server || DEFAULT_SERVER || "").replace(/\/+$/, "") || null,
|
|
22
|
+
key: env.PHYLL_API_KEY || file.key || null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveCredentials({ server, key }, env = process.env) {
|
|
27
|
+
mkdirSync(phyllHome(env), { recursive: true, mode: 0o700 });
|
|
28
|
+
const path = credentialsPath(env);
|
|
29
|
+
writeFileSync(path, JSON.stringify({ server, key }, null, 2) + "\n", { mode: 0o600 });
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(path, 0o600);
|
|
32
|
+
} catch {
|
|
33
|
+
// Windows keeps its own permissions on the user folder.
|
|
34
|
+
}
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearCredentials(env = process.env) {
|
|
39
|
+
rmSync(credentialsPath(env), { force: true });
|
|
40
|
+
}
|
package/src/engine.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Calls to the Phyll engine. Every call answers { ok, status, json } and never throws, so the
|
|
2
|
+
// agent always gets a message it can pass on.
|
|
3
|
+
const LOCAL = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
4
|
+
|
|
5
|
+
export function engineClient({ server, key = null, version = "0", fetchImpl = globalThis.fetch }) {
|
|
6
|
+
const base = String(server ?? "").replace(/\/+$/, "");
|
|
7
|
+
async function call(method, path, body) {
|
|
8
|
+
if (!base) return { ok: false, status: 0, json: { message: "No Phyll server is set. Sign up with npx phyll signup you@example.com --server <address>." } };
|
|
9
|
+
if (key && !/^https:\/\//i.test(base) && !LOCAL.test(base)) {
|
|
10
|
+
return { ok: false, status: 0, json: { message: `Phyll sends your key only over https, and ${base} is not.` } };
|
|
11
|
+
}
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetchImpl(`${base}${path}`, {
|
|
15
|
+
method,
|
|
16
|
+
headers: {
|
|
17
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
18
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
19
|
+
"user-agent": `phyll/${version}`,
|
|
20
|
+
},
|
|
21
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return { ok: false, status: 0, json: { message: `Could not reach Phyll at ${base} (${error?.cause?.code ?? error?.message ?? error}).` } };
|
|
25
|
+
}
|
|
26
|
+
const json = await response.json().catch(() => ({}));
|
|
27
|
+
return { ok: response.ok, status: response.status, json: json ?? {} };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
base,
|
|
31
|
+
signup: (email, language) => call("POST", "/v1/signup", { email, language }),
|
|
32
|
+
me: () => call("GET", "/v1/me"),
|
|
33
|
+
startSession: (body) => call("POST", "/v1/sessions", body),
|
|
34
|
+
guide: (id, name, tells = []) =>
|
|
35
|
+
call("GET", `/v1/sessions/${encodeURIComponent(id)}/guides/${encodeURIComponent(name)}${tells.length ? `?tells=${encodeURIComponent(tells.join(","))}` : ""}`),
|
|
36
|
+
submitReport: (id, report) => call("POST", `/v1/sessions/${encodeURIComponent(id)}/report`, { report }),
|
|
37
|
+
checkout: () => call("POST", "/v1/billing/checkout"),
|
|
38
|
+
portal: () => call("POST", "/v1/billing/portal"),
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The connector as an MCP server on stdio, for Codex, Claude Code or any agent that speaks MCP.
|
|
2
|
+
// Nothing here writes to stdout: that stream belongs to the protocol.
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { VERSION } from "./paths.mjs";
|
|
7
|
+
import { Connector } from "./review.mjs";
|
|
8
|
+
|
|
9
|
+
const INSTRUCTIONS =
|
|
10
|
+
"Phyll reviews the UX of apps built with AI, the way a first-time user meets them, and keeps their design. When the person asks to review, audit or improve the UX, flows or onboarding of the app in this project, call start_review with the address where the app runs and follow the method it returns, step by step. The scan tool reads the source for AI tells and needs no account.";
|
|
11
|
+
|
|
12
|
+
export function createServer(connector) {
|
|
13
|
+
const server = new McpServer({ name: "phyll", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
14
|
+
const reply = (result) => {
|
|
15
|
+
if (result.image) {
|
|
16
|
+
return { content: [{ type: "image", data: result.image, mimeType: "image/png" }, { type: "text", text: result.text }] };
|
|
17
|
+
}
|
|
18
|
+
return { content: [{ type: "text", text: result.text }], ...(result.isError ? { isError: true } : {}) };
|
|
19
|
+
};
|
|
20
|
+
const safely = (fn) => async (args) => {
|
|
21
|
+
try {
|
|
22
|
+
return reply(await fn(args ?? {}));
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return reply({ text: `That did not work: ${error?.message ?? error}`, isError: true });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const tool = (name, description, inputSchema, fn) => server.registerTool(name, { description, inputSchema }, safely(fn));
|
|
28
|
+
const browser = (fn) => async (args) => ({ text: await fn(await connector.browser(), args) });
|
|
29
|
+
|
|
30
|
+
tool(
|
|
31
|
+
"start_review",
|
|
32
|
+
"Start a Phyll review of a running app. Scans the project's source, creates the report folder and returns the method to follow. Uses one of the account's reviews.",
|
|
33
|
+
{
|
|
34
|
+
url: z.string().describe("Where the app runs, such as http://localhost:3000"),
|
|
35
|
+
language: z.string().optional().describe("Language of the report, such as en or pt-BR. Use the language the person writes in."),
|
|
36
|
+
user: z.string().optional().describe("Who uses the app, when the person said it"),
|
|
37
|
+
jobs: z.array(z.string()).optional().describe("The core jobs of the app, when the person named them"),
|
|
38
|
+
name: z.string().optional().describe("Name of the product, for the report's project"),
|
|
39
|
+
project_dir: z.string().optional().describe("The project folder, when it is not the folder the agent runs in"),
|
|
40
|
+
},
|
|
41
|
+
(args) => connector.startReview(args),
|
|
42
|
+
);
|
|
43
|
+
tool(
|
|
44
|
+
"capture",
|
|
45
|
+
"Save a screenshot and the probe of each route at laptop and phone size, in the report folder. Without routes, captures the routes the scan found.",
|
|
46
|
+
{ routes: z.array(z.string()).optional().describe("Routes such as / and /pricing") },
|
|
47
|
+
(args) => connector.capture(args),
|
|
48
|
+
);
|
|
49
|
+
tool(
|
|
50
|
+
"open",
|
|
51
|
+
"Open a page of the app by path, such as /pricing, or by a full URL on the same site. Pages on other sites are refused.",
|
|
52
|
+
{ path: z.string() },
|
|
53
|
+
browser((session, { path }) => session.open(path)),
|
|
54
|
+
);
|
|
55
|
+
tool(
|
|
56
|
+
"snapshot",
|
|
57
|
+
"Read the current page as an accessibility tree: headings, text, links, buttons and fields with their names. Take one before clicking.",
|
|
58
|
+
{},
|
|
59
|
+
browser((session) => session.snapshot()),
|
|
60
|
+
);
|
|
61
|
+
tool(
|
|
62
|
+
"click",
|
|
63
|
+
"Click something the way a person would: by role and accessible name, such as role button and name Save, or by visible text. Reports dialogs, new tabs and JavaScript errors that followed.",
|
|
64
|
+
{
|
|
65
|
+
role: z.string().optional(),
|
|
66
|
+
name: z.string().optional(),
|
|
67
|
+
text: z.string().optional(),
|
|
68
|
+
exact: z.boolean().optional(),
|
|
69
|
+
nth: z.number().int().min(0).optional(),
|
|
70
|
+
},
|
|
71
|
+
browser((session, args) => session.click(args)),
|
|
72
|
+
);
|
|
73
|
+
tool(
|
|
74
|
+
"fill",
|
|
75
|
+
"Type into a field found by its label, or by its placeholder when it has no label. Use obvious test data, never real personal data.",
|
|
76
|
+
{ label: z.string().optional(), placeholder: z.string().optional(), value: z.string() },
|
|
77
|
+
browser((session, args) => session.fill(args)),
|
|
78
|
+
);
|
|
79
|
+
tool("select", "Choose an option in a list found by its label.", { label: z.string(), option: z.string() }, browser((session, args) => session.select(args)));
|
|
80
|
+
tool(
|
|
81
|
+
"check",
|
|
82
|
+
"Check or uncheck a checkbox or a radio button found by its label.",
|
|
83
|
+
{ label: z.string(), checked: z.boolean().optional() },
|
|
84
|
+
browser((session, args) => session.check(args)),
|
|
85
|
+
);
|
|
86
|
+
tool("press", "Press a key, such as Tab, Enter or Escape, and report where the focus went.", { key: z.string() }, browser((session, args) => session.press(args)));
|
|
87
|
+
tool("back", "Go back to the previous page.", {}, browser((session) => session.back()));
|
|
88
|
+
tool(
|
|
89
|
+
"screenshot",
|
|
90
|
+
"Save a screenshot of the current page in the report folder as evidence, and look at it. Name it after the moment, such as signup-empty.",
|
|
91
|
+
{ name: z.string(), fullPage: z.boolean().optional() },
|
|
92
|
+
async (args) => {
|
|
93
|
+
const shot = await (await connector.browser()).screenshot(args);
|
|
94
|
+
return { image: shot.data, text: `Saved ${shot.path}.` };
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
tool(
|
|
98
|
+
"probe",
|
|
99
|
+
"Measure the current page: text contrast, button sizes, unnamed icon buttons, dead links, form fields and decoration.",
|
|
100
|
+
{ name: z.string() },
|
|
101
|
+
async (args) => {
|
|
102
|
+
const result = await (await connector.browser()).probe(args);
|
|
103
|
+
return { text: `Saved ${result.path}.\n${result.summary}` };
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
tool(
|
|
107
|
+
"resize",
|
|
108
|
+
"Switch between laptop size (desktop, 1440 by 900) and phone size (mobile, 390 by 844). The page loads again.",
|
|
109
|
+
{ size: z.enum(["desktop", "mobile"]) },
|
|
110
|
+
browser((session, args) => session.resize(args)),
|
|
111
|
+
);
|
|
112
|
+
tool(
|
|
113
|
+
"guide",
|
|
114
|
+
"Get one of Phyll's guides for this review: walkthrough, heuristics, report-format, fixing, or tells with the ids you need.",
|
|
115
|
+
{
|
|
116
|
+
name: z.enum(["walkthrough", "heuristics", "report-format", "fixing", "tells"]),
|
|
117
|
+
tells: z.array(z.string()).optional().describe("Tell ids such as F05 and L01, for the tells guide"),
|
|
118
|
+
},
|
|
119
|
+
(args) => connector.guide(args),
|
|
120
|
+
);
|
|
121
|
+
tool(
|
|
122
|
+
"finish_review",
|
|
123
|
+
"Send report.json to Phyll, which checks it, scores it, keeps it with a link and writes report.md. If it lists problems, fix the file and call it again.",
|
|
124
|
+
{},
|
|
125
|
+
() => connector.finishReview(),
|
|
126
|
+
);
|
|
127
|
+
tool("scan", "Scan a project's source for AI tells, with no account and no AI. Returns the static index and the tells found.", { dir: z.string().optional() }, (args) => connector.scan(args));
|
|
128
|
+
tool("account", "Show the Phyll plan and how many free reviews are left.", {}, () => connector.account());
|
|
129
|
+
tool("upgrade", "Get the link for the person to subscribe to Phyll Pro.", {}, () => connector.upgrade());
|
|
130
|
+
return server;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function runMcpServer(options = {}) {
|
|
134
|
+
const connector = new Connector(options);
|
|
135
|
+
const server = createServer(connector);
|
|
136
|
+
await server.connect(new StdioServerTransport());
|
|
137
|
+
const stop = async () => {
|
|
138
|
+
await connector.close();
|
|
139
|
+
// Let the process end on its own: exiting inside the stdin close callback trips a libuv
|
|
140
|
+
// assertion on Windows. The timer only fires if something still holds the event loop.
|
|
141
|
+
setTimeout(() => process.exit(0), 1000).unref();
|
|
142
|
+
};
|
|
143
|
+
process.stdin.on("close", stop);
|
|
144
|
+
process.on("SIGTERM", stop);
|
|
145
|
+
}
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Where the connector finds the scanner and the probe: skills/phyll when it runs from the
|
|
2
|
+
// repository, or the copy packed next to it on npm. The copy holds only the open parts.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
export const PACKAGE_DIR = join(HERE, "..");
|
|
9
|
+
const REPO = join(PACKAGE_DIR, "..", "..");
|
|
10
|
+
const IN_REPO = existsSync(join(REPO, "skills", "phyll", "scripts", "scan.mjs")) && existsSync(join(REPO, "packages", "connector", "package.json"));
|
|
11
|
+
|
|
12
|
+
export const SKILL_DIR = IN_REPO ? join(REPO, "skills", "phyll") : join(PACKAGE_DIR, "skill");
|
|
13
|
+
export const BIN = join(PACKAGE_DIR, "bin", "phyll.mjs");
|
|
14
|
+
export const VERSION = JSON.parse(readFileSync(join(PACKAGE_DIR, "package.json"), "utf8")).version;
|
|
15
|
+
// Installed from npm, the package sits inside a node_modules folder; from the repository it does not.
|
|
16
|
+
export const PUBLISHED = PACKAGE_DIR.split(/[\\/]/).includes("node_modules");
|
|
17
|
+
|
|
18
|
+
export const importSkill = (relative) => import(pathToFileURL(join(SKILL_DIR, relative)).href);
|
|
19
|
+
export const probeSource = () => readFileSync(join(SKILL_DIR, "scripts", "probe.js"), "utf8");
|
package/src/review.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// What each connector tool does. The agent that calls them does the thinking on its owner's
|
|
2
|
+
// plan; the connector keeps the browser, the files and the key on this machine and asks the
|
|
3
|
+
// engine for the method, the guides and the finished report.
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, join, resolve } from "node:path";
|
|
6
|
+
import { BrowserSession, captureWithSession } from "./browser.mjs";
|
|
7
|
+
import { loadCredentials } from "./credentials.mjs";
|
|
8
|
+
import { engineClient } from "./engine.mjs";
|
|
9
|
+
import { importSkill, probeSource, VERSION } from "./paths.mjs";
|
|
10
|
+
|
|
11
|
+
export const timestamp = (date = new Date()) => date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
12
|
+
|
|
13
|
+
export function normalizeUrl(input) {
|
|
14
|
+
const text = String(input ?? "").trim();
|
|
15
|
+
if (!text) throw new Error("give the address of the running app, such as http://localhost:3000");
|
|
16
|
+
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `http://${text}`);
|
|
17
|
+
if (!/^https?:$/.test(url.protocol)) throw new Error("the address must start with http:// or https://");
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function localeLanguage() {
|
|
22
|
+
const locale = Intl.DateTimeFormat().resolvedOptions().locale ?? "";
|
|
23
|
+
return locale.toLowerCase().startsWith("pt") ? "pt-BR" : "en";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function describeCapture(manifest) {
|
|
27
|
+
const pages = manifest?.pages ?? [];
|
|
28
|
+
if (!pages.length) return "No screen could be captured, so capture what you need with the browser tools.";
|
|
29
|
+
const lines = pages.map((p) => {
|
|
30
|
+
const files = [p.screenshot, p.probe].filter(Boolean).join(", ");
|
|
31
|
+
const trouble = p.error ? ` Could not capture: ${p.error}.` : "";
|
|
32
|
+
const errors = p.consoleErrors?.length ? ` JavaScript errors: ${p.consoleErrors.slice(0, 2).join(" | ")}.` : "";
|
|
33
|
+
return `- ${p.route} at ${p.viewport} size${p.status ? `, HTTP ${p.status}` : ""}: ${files || "no files"}.${trouble}${errors}`;
|
|
34
|
+
});
|
|
35
|
+
return ["Captured screens, with the probe result for each, saved in capture.json:", ...lines].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function finishText(result, reportRel) {
|
|
39
|
+
const report = result.report ?? {};
|
|
40
|
+
const summary = report.summary ?? {};
|
|
41
|
+
const byId = new Map((report.findings ?? []).map((f) => [f.id, f]));
|
|
42
|
+
const top = (report.topThree ?? []).map((id) => byId.get(id)).filter(Boolean);
|
|
43
|
+
const previous = typeof report.history?.previousIndex === "number" ? `, previous ${report.history.previousIndex}` : "";
|
|
44
|
+
return [
|
|
45
|
+
result.message,
|
|
46
|
+
`AI tell index: ${summary.aiTellIndex ?? "n/a"}/100${previous}. Lower is better.`,
|
|
47
|
+
summary.inputs ? `Inputs asked / needed on the core jobs: ${summary.inputs.asked} / ${summary.inputs.needed}.` : null,
|
|
48
|
+
top.length ? "What blocks people most:" : null,
|
|
49
|
+
...top.map((f, i) => `${i + 1}. ${f.title} (${f.severity})`),
|
|
50
|
+
`Report on this machine: ${reportRel}/report.md`,
|
|
51
|
+
`Link: ${result.url}`,
|
|
52
|
+
"Tell the person the index, these findings and where the report is, and offer to fix them.",
|
|
53
|
+
]
|
|
54
|
+
.filter((line) => line !== null && line !== undefined)
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const NOT_CONNECTED =
|
|
59
|
+
"Phyll is not connected on this computer yet. Ask the person to run npx phyll signup their@email.com in a terminal (or npx phyll login <key> if they already have an account), then try again.";
|
|
60
|
+
|
|
61
|
+
export class Connector {
|
|
62
|
+
constructor({ env = process.env, cwd = process.cwd(), fetchImpl = globalThis.fetch, loadPlaywright = () => import("playwright"), now = () => new Date() } = {}) {
|
|
63
|
+
this.env = env;
|
|
64
|
+
this.cwd = cwd;
|
|
65
|
+
this.fetchImpl = fetchImpl;
|
|
66
|
+
this.loadPlaywright = loadPlaywright;
|
|
67
|
+
this.now = now;
|
|
68
|
+
this.review = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
engine() {
|
|
72
|
+
const { server, key } = loadCredentials(this.env);
|
|
73
|
+
return key && server ? engineClient({ server, key, version: VERSION, fetchImpl: this.fetchImpl }) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The review of this session, or the newest one in the project, so fixes can happen in a new
|
|
77
|
+
// conversation after the review ended.
|
|
78
|
+
current() {
|
|
79
|
+
if (this.review) return this.review;
|
|
80
|
+
const reports = join(this.cwd, ".phyll", "reports");
|
|
81
|
+
const newest = existsSync(reports)
|
|
82
|
+
? readdirSync(reports)
|
|
83
|
+
.filter((name) => existsSync(join(reports, name, "session.json")))
|
|
84
|
+
.sort()
|
|
85
|
+
.at(-1)
|
|
86
|
+
: null;
|
|
87
|
+
if (!newest) throw new Error("no review has started. Call start_review with the address of the app first.");
|
|
88
|
+
const saved = JSON.parse(readFileSync(join(reports, newest, "session.json"), "utf8"));
|
|
89
|
+
this.review = { ...saved, project: this.cwd, reportDir: join(reports, newest), reportRel: `.phyll/reports/${newest}`, browser: null };
|
|
90
|
+
return this.review;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async browser() {
|
|
94
|
+
const review = this.current();
|
|
95
|
+
if (!review.browser) {
|
|
96
|
+
const mod = await this.loadPlaywright();
|
|
97
|
+
const playwright = mod.chromium ? mod : mod.default;
|
|
98
|
+
const session = new BrowserSession({ playwright, baseUrl: review.url, out: review.reportDir, probeSource: probeSource() });
|
|
99
|
+
try {
|
|
100
|
+
await session.start();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const missing = /Executable doesn't exist|playwright install/i.test(error?.message ?? "");
|
|
103
|
+
throw new Error(missing ? "the browser is not installed. Ask the person to run npx phyll setup codex (or claude) once." : `the browser did not start: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
review.browser = session;
|
|
106
|
+
}
|
|
107
|
+
return review.browser;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async scan({ dir } = {}) {
|
|
111
|
+
const { scan, formatText } = await importSkill("scripts/scan.mjs");
|
|
112
|
+
return { text: formatText(scan(resolve(this.cwd, dir ?? "."))) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async startReview({ url, name, language, user, jobs, project_dir: projectDir } = {}) {
|
|
116
|
+
const engine = this.engine();
|
|
117
|
+
if (!engine) return { text: NOT_CONNECTED, isError: true };
|
|
118
|
+
const target = normalizeUrl(url);
|
|
119
|
+
const project = resolve(this.cwd, projectDir ?? ".");
|
|
120
|
+
if (!existsSync(project)) throw new Error(`the project folder ${project} does not exist`);
|
|
121
|
+
const { configPath, readConfigFile } = await importSkill("scripts/lib/config.mjs");
|
|
122
|
+
const config = readConfigFile(configPath(project));
|
|
123
|
+
const reportRel = `.phyll/reports/${timestamp(this.now())}`;
|
|
124
|
+
const reportDir = join(project, ...reportRel.split("/"));
|
|
125
|
+
mkdirSync(reportDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
const { scan } = await importSkill("scripts/scan.mjs");
|
|
128
|
+
const result = scan(project, { ignore: config.ignore ?? [] });
|
|
129
|
+
writeFileSync(join(reportDir, "scan.json"), JSON.stringify(result, null, 2) + "\n");
|
|
130
|
+
const routes = (result.structure?.routes ?? []).map((r) => r.path);
|
|
131
|
+
const summary = {
|
|
132
|
+
staticIndex: result.staticIndex,
|
|
133
|
+
tells: result.tells.filter((t) => t.hits > 0).map((t) => ({ id: t.id, hits: t.hits })),
|
|
134
|
+
structure: {
|
|
135
|
+
routes: routes.map((path) => ({ path })),
|
|
136
|
+
forms: (result.structure?.forms ?? []).map((f) => ({ file: f.file, line: f.line, fields: f.fields })),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const lang = language || config.language || localeLanguage();
|
|
141
|
+
const answer = await engine.startSession({
|
|
142
|
+
url: target,
|
|
143
|
+
name: name || config.name || basename(project),
|
|
144
|
+
language: lang,
|
|
145
|
+
user: user || config.endUser || undefined,
|
|
146
|
+
jobs: jobs?.length ? jobs : Array.isArray(config.coreJobs) ? config.coreJobs : [],
|
|
147
|
+
reportFolder: reportRel,
|
|
148
|
+
scan: summary,
|
|
149
|
+
});
|
|
150
|
+
if (!answer.ok) {
|
|
151
|
+
rmSync(reportDir, { recursive: true, force: true });
|
|
152
|
+
const link = answer.json.checkoutUrl ? `\nCheckout: ${answer.json.checkoutUrl}` : "";
|
|
153
|
+
return { text: `${answer.json.message ?? `Phyll answered ${answer.status}.`}${link}`, isError: answer.status !== 402 };
|
|
154
|
+
}
|
|
155
|
+
await this.review?.browser?.close();
|
|
156
|
+
const { staticRoutes } = await importSkill("scripts/capture.mjs");
|
|
157
|
+
this.review = { id: answer.json.id, url: target, project, reportDir, reportRel, routes: staticRoutes(routes), browser: null };
|
|
158
|
+
writeFileSync(join(reportDir, "session.json"), JSON.stringify({ id: answer.json.id, url: target, routes: this.review.routes }, null, 2) + "\n");
|
|
159
|
+
return { text: [answer.json.message, `Report folder: ${reportRel}`, "", answer.json.instructions].join("\n") };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async capture({ routes } = {}) {
|
|
163
|
+
const review = this.current();
|
|
164
|
+
const { staticRoutes, uniqueSlugs } = await importSkill("scripts/capture.mjs");
|
|
165
|
+
let list = staticRoutes(routes?.length ? routes : review.routes).slice(0, 12);
|
|
166
|
+
if (!list.length) list = ["/"];
|
|
167
|
+
const session = await this.browser();
|
|
168
|
+
return { text: describeCapture(await captureWithSession({ session, routes: list, out: review.reportDir, slugs: uniqueSlugs(list) })) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async guide({ name, tells = [] } = {}) {
|
|
172
|
+
const review = this.current();
|
|
173
|
+
const engine = this.engine();
|
|
174
|
+
if (!engine) return { text: NOT_CONNECTED, isError: true };
|
|
175
|
+
const answer = await engine.guide(review.id, name, tells);
|
|
176
|
+
return answer.ok ? { text: answer.json.text } : { text: answer.json.message ?? `Phyll answered ${answer.status}.`, isError: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async finishReview() {
|
|
180
|
+
const review = this.current();
|
|
181
|
+
const engine = this.engine();
|
|
182
|
+
if (!engine) return { text: NOT_CONNECTED, isError: true };
|
|
183
|
+
const path = join(review.reportDir, "report.json");
|
|
184
|
+
if (!existsSync(path)) return { text: `report.json is missing in ${review.reportRel}. Write it there first.`, isError: true };
|
|
185
|
+
let report;
|
|
186
|
+
try {
|
|
187
|
+
report = JSON.parse(readFileSync(path, "utf8"));
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return { text: `report.json is not valid JSON: ${error.message}`, isError: true };
|
|
190
|
+
}
|
|
191
|
+
const answer = await engine.submitReport(review.id, report);
|
|
192
|
+
if (!answer.ok) {
|
|
193
|
+
const problems = (answer.json.errors ?? []).map((e) => `- ${e}`);
|
|
194
|
+
return { text: [answer.json.message ?? `Phyll answered ${answer.status}.`, ...problems].join("\n"), isError: true };
|
|
195
|
+
}
|
|
196
|
+
writeFileSync(path, JSON.stringify(answer.json.report, null, 2) + "\n");
|
|
197
|
+
writeFileSync(join(review.reportDir, "report.md"), answer.json.markdown);
|
|
198
|
+
await review.browser?.close();
|
|
199
|
+
review.browser = null;
|
|
200
|
+
return { text: finishText(answer.json, review.reportRel) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async account() {
|
|
204
|
+
const engine = this.engine();
|
|
205
|
+
if (!engine) return { text: NOT_CONNECTED, isError: true };
|
|
206
|
+
const answer = await engine.me();
|
|
207
|
+
if (!answer.ok) return { text: answer.json.message ?? `Phyll answered ${answer.status}.`, isError: true };
|
|
208
|
+
const me = answer.json;
|
|
209
|
+
const sessions = me.sessions ?? {};
|
|
210
|
+
const left = me.plan === "pro" ? "Phyll Pro, unlimited reviews" : `${Math.max(0, (sessions.limit ?? 0) - (sessions.used ?? 0))} of ${sessions.limit} free reviews left`;
|
|
211
|
+
return { text: `${me.email}: ${left}.` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async upgrade() {
|
|
215
|
+
const engine = this.engine();
|
|
216
|
+
if (!engine) return { text: NOT_CONNECTED, isError: true };
|
|
217
|
+
const answer = await engine.checkout();
|
|
218
|
+
return answer.ok
|
|
219
|
+
? { text: `Ask the person to open this link to subscribe to Phyll Pro: ${answer.json.url}` }
|
|
220
|
+
: { text: answer.json.message ?? `Phyll answered ${answer.status}.`, isError: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async close() {
|
|
224
|
+
await this.review?.browser?.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Connects Phyll to the person's agent: an MCP server entry in Codex's config.toml, or a
|
|
2
|
+
// `claude mcp add` for Claude Code, and the Chromium build Playwright needs.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { BIN, PUBLISHED } from "./paths.mjs";
|
|
9
|
+
|
|
10
|
+
// How the agent starts the connector: through npx when installed from npm, or straight from
|
|
11
|
+
// this folder when it runs from the repository.
|
|
12
|
+
export function mcpCommand({ published = PUBLISHED, platform = process.platform, node = process.execPath, bin = BIN } = {}) {
|
|
13
|
+
if (!published) return [node, bin, "mcp"];
|
|
14
|
+
const npx = ["npx", "-y", "phyll@latest", "mcp"];
|
|
15
|
+
return platform === "win32" ? ["cmd", "/c", ...npx] : npx;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tomlString = (value) => JSON.stringify(String(value));
|
|
19
|
+
|
|
20
|
+
export function codexBlock(command) {
|
|
21
|
+
return `[mcp_servers.phyll]\ncommand = ${tomlString(command[0])}\nargs = [${command.slice(1).map(tomlString).join(", ")}]\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Removes [mcp_servers.phyll] and its sub-tables, keeping everything else in the file as it was.
|
|
25
|
+
export function withoutTable(text, name) {
|
|
26
|
+
const kept = [];
|
|
27
|
+
let skipping = false;
|
|
28
|
+
for (const line of text.split("\n")) {
|
|
29
|
+
const header = line.trim().match(/^\[\[?\s*([^\]]+?)\s*\]\]?$/);
|
|
30
|
+
if (header) skipping = header[1] === name || header[1].startsWith(`${name}.`);
|
|
31
|
+
if (!skipping) kept.push(line);
|
|
32
|
+
}
|
|
33
|
+
return kept.join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function writeCodexConfig(command, env = process.env) {
|
|
37
|
+
const dir = env.CODEX_HOME || join(homedir(), ".codex");
|
|
38
|
+
const file = join(dir, "config.toml");
|
|
39
|
+
const before = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
40
|
+
const rest = withoutTable(before, "mcp_servers.phyll").replace(/\s+$/, "");
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(file, `${rest ? `${rest}\n\n` : ""}${codexBlock(command)}`);
|
|
43
|
+
return file;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const quote = (arg) => (/[\s"]/.test(arg) ? `"${arg.replaceAll('"', '\\"')}"` : arg);
|
|
47
|
+
|
|
48
|
+
export function registerClaude(command, run = spawnSync) {
|
|
49
|
+
const args = ["mcp", "add", "--scope", "user", "phyll", "--", ...command];
|
|
50
|
+
const call = (list) => run("claude", list.map(quote), { shell: true, encoding: "utf8" });
|
|
51
|
+
call(["mcp", "remove", "phyll", "--scope", "user"]);
|
|
52
|
+
const added = call(args);
|
|
53
|
+
if (added.error || added.status !== 0) return { ok: false, manual: `claude ${args.map(quote).join(" ")}` };
|
|
54
|
+
return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Opens Chromium once; when Playwright says it is missing, installs it.
|
|
58
|
+
export async function ensureBrowser({ write = () => {}, run = spawnSync } = {}) {
|
|
59
|
+
const { chromium } = await import("playwright");
|
|
60
|
+
try {
|
|
61
|
+
await (await chromium.launch()).close();
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!/Executable doesn't exist|playwright install/i.test(error?.message ?? "")) throw error;
|
|
65
|
+
}
|
|
66
|
+
write("Installing the browser Phyll uses, Chromium (about 150 MB)...\n");
|
|
67
|
+
const cli = createRequire(import.meta.url).resolve("playwright/cli");
|
|
68
|
+
return run(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" }).status === 0;
|
|
69
|
+
}
|