@voila.dev/cliche 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/apps/album/src/app.ts +107 -0
- package/apps/album/src/index.html +38 -0
- package/apps/album/src/index.ts +12 -0
- package/apps/album/src/shots.ts +85 -0
- package/apps/album/src/styles.css +66 -0
- package/dist/capture.d.ts +28 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +115 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +107 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +21 -0
- package/dist/keys.js.map +1 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +193 -0
- package/dist/mcp.js.map +1 -0
- package/dist/options.d.ts +38 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +91 -0
- package/dist/options.js.map +1 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +85 -0
- package/dist/setup.js.map +1 -0
- package/dist/skill.d.ts +8 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +13 -0
- package/dist/skill.js.map +1 -0
- package/dist/upload.d.ts +27 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +51 -0
- package/dist/upload.js.map +1 -0
- package/package.json +57 -0
- package/skill/SKILL.md +65 -0
- package/src/capture.ts +85 -0
- package/src/cli.ts +101 -0
- package/src/index.ts +7 -0
- package/src/keys.ts +24 -0
- package/src/mcp.ts +220 -0
- package/src/options.ts +141 -0
- package/src/setup.ts +104 -0
- package/src/skill.ts +14 -0
- package/src/upload.ts +72 -0
package/dist/options.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
export function parseViewport(value) {
|
|
3
|
+
const match = value.match(/^(\d+)x(\d+)$/);
|
|
4
|
+
if (match === null) {
|
|
5
|
+
throw new Error(`Invalid --viewport ${value}: expected <width>x<height>, e.g. 1440x900.`);
|
|
6
|
+
}
|
|
7
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
8
|
+
}
|
|
9
|
+
export function parseLocalStorage(entries) {
|
|
10
|
+
const parsed = {};
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
const separator = entry.indexOf("=");
|
|
13
|
+
if (separator < 1) {
|
|
14
|
+
throw new Error(`Invalid --local-storage ${entry}: expected key=value.`);
|
|
15
|
+
}
|
|
16
|
+
parsed[entry.slice(0, separator)] = entry.slice(separator + 1);
|
|
17
|
+
}
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
export function parseCommand(argv) {
|
|
21
|
+
const { values, positionals } = parseArgs({
|
|
22
|
+
args: [...argv],
|
|
23
|
+
allowPositionals: true,
|
|
24
|
+
options: {
|
|
25
|
+
viewport: { type: "string" },
|
|
26
|
+
"wait-for": { type: "string" },
|
|
27
|
+
"scroll-to": { type: "string" },
|
|
28
|
+
settle: { type: "string" },
|
|
29
|
+
"local-storage": { type: "string", multiple: true },
|
|
30
|
+
"full-page": { type: "boolean" },
|
|
31
|
+
upload: { type: "boolean" },
|
|
32
|
+
prefix: { type: "string" },
|
|
33
|
+
markdown: { type: "boolean" },
|
|
34
|
+
bucket: { type: "string" },
|
|
35
|
+
port: { type: "string" },
|
|
36
|
+
help: { type: "boolean", short: "h" },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (values.help === true || positionals.length === 0) {
|
|
40
|
+
return { kind: "help" };
|
|
41
|
+
}
|
|
42
|
+
if (positionals[0] === "mcp") {
|
|
43
|
+
return { kind: "mcp" };
|
|
44
|
+
}
|
|
45
|
+
if (positionals[0] === "skill") {
|
|
46
|
+
return { kind: "skill" };
|
|
47
|
+
}
|
|
48
|
+
if (positionals[0] === "setup") {
|
|
49
|
+
return { kind: "setup", bucket: values.bucket ?? "cliche-shots" };
|
|
50
|
+
}
|
|
51
|
+
if (positionals[0] === "album") {
|
|
52
|
+
const port = values.port === undefined ? undefined : Number(values.port);
|
|
53
|
+
if (port !== undefined && !Number.isInteger(port)) {
|
|
54
|
+
throw new Error(`Invalid --port ${values.port}: expected a number.`);
|
|
55
|
+
}
|
|
56
|
+
return { kind: "album", port };
|
|
57
|
+
}
|
|
58
|
+
if (positionals[0] === "upload") {
|
|
59
|
+
const files = positionals.slice(1);
|
|
60
|
+
if (files.length === 0)
|
|
61
|
+
throw new Error("upload: pass at least one image file.");
|
|
62
|
+
return { kind: "upload", files, prefix: values.prefix, markdown: values.markdown === true };
|
|
63
|
+
}
|
|
64
|
+
const [url, out] = positionals;
|
|
65
|
+
if (url === undefined || out === undefined) {
|
|
66
|
+
throw new Error("capture: pass <url> <out.png> (or see --help).");
|
|
67
|
+
}
|
|
68
|
+
const settle = values.settle === undefined ? undefined : Number(values.settle);
|
|
69
|
+
if (settle !== undefined && !Number.isFinite(settle)) {
|
|
70
|
+
throw new Error(`Invalid --settle ${values.settle}: expected milliseconds.`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
kind: "capture",
|
|
74
|
+
capture: {
|
|
75
|
+
url,
|
|
76
|
+
out,
|
|
77
|
+
...(values.viewport === undefined ? {} : { viewport: parseViewport(values.viewport) }),
|
|
78
|
+
...(values["wait-for"] === undefined ? {} : { waitFor: values["wait-for"] }),
|
|
79
|
+
...(values["scroll-to"] === undefined ? {} : { scrollTo: values["scroll-to"] }),
|
|
80
|
+
...(settle === undefined ? {} : { settleMilliseconds: settle }),
|
|
81
|
+
...(values["local-storage"] === undefined
|
|
82
|
+
? {}
|
|
83
|
+
: { localStorage: parseLocalStorage(values["local-storage"]) }),
|
|
84
|
+
...(values["full-page"] === true ? { fullPage: true } : {}),
|
|
85
|
+
},
|
|
86
|
+
upload: values.upload === true,
|
|
87
|
+
prefix: values.prefix,
|
|
88
|
+
markdown: values.markdown === true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=options.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"options.js","sourceRoot":"","sources":["../src/options.ts"],"names":[],"mappings":"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"}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-command S3 setup on Cloudflare R2, riding the user's `wrangler login`
|
|
3
|
+
* session: create the bucket, enable its managed public r2.dev URL, and
|
|
4
|
+
* write the resulting configuration to `.env`. The only step wrangler cannot
|
|
5
|
+
* do is minting S3 API keys — the dashboard link for that is printed (and
|
|
6
|
+
* left as a comment in `.env`).
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseAccountId(whoamiOutput: string): string | null;
|
|
9
|
+
export declare function parsePublicUrl(devUrlOutput: string): string | null;
|
|
10
|
+
export declare function setup(bucket: string): Promise<void>;
|
|
11
|
+
//# sourceMappingURL=setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../src/setup.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAsBH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIlE;AAED,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAElE;AAuBD,wBAAsB,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA4CzD"}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-command S3 setup on Cloudflare R2, riding the user's `wrangler login`
|
|
3
|
+
* session: create the bucket, enable its managed public r2.dev URL, and
|
|
4
|
+
* write the resulting configuration to `.env`. The only step wrangler cannot
|
|
5
|
+
* do is minting S3 API keys — the dashboard link for that is printed (and
|
|
6
|
+
* left as a comment in `.env`).
|
|
7
|
+
*/
|
|
8
|
+
const DASHBOARD_TOKENS_URL = "https://dash.cloudflare.com/?to=/:account/r2/api-tokens";
|
|
9
|
+
async function wrangler(args, options = {}) {
|
|
10
|
+
const subprocess = Bun.spawn(["bunx", "wrangler", ...args], {
|
|
11
|
+
stdin: "inherit",
|
|
12
|
+
stdout: options.interactive === true ? "inherit" : "pipe",
|
|
13
|
+
stderr: options.interactive === true ? "inherit" : "pipe",
|
|
14
|
+
});
|
|
15
|
+
const exitCode = await subprocess.exited;
|
|
16
|
+
const output = options.interactive === true
|
|
17
|
+
? ""
|
|
18
|
+
: (await new Response(subprocess.stdout).text()) +
|
|
19
|
+
(await new Response(subprocess.stderr).text());
|
|
20
|
+
return { exitCode, output };
|
|
21
|
+
}
|
|
22
|
+
export function parseAccountId(whoamiOutput) {
|
|
23
|
+
// `wrangler whoami` prints an account table; a lone account is unambiguous.
|
|
24
|
+
const ids = [...new Set(whoamiOutput.match(/\b[0-9a-f]{32}\b/g) ?? [])];
|
|
25
|
+
return ids.length === 1 ? (ids[0] ?? null) : null;
|
|
26
|
+
}
|
|
27
|
+
export function parsePublicUrl(devUrlOutput) {
|
|
28
|
+
return devUrlOutput.match(/https:\/\/[a-z0-9-]+\.r2\.dev/)?.[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
function environmentBlock(bucket, publicUrl, accountId) {
|
|
31
|
+
const lines = [
|
|
32
|
+
"",
|
|
33
|
+
"# cliche — added by `cliche setup` (https://cliche.voila.dev)",
|
|
34
|
+
`S3_BUCKET=${bucket}`,
|
|
35
|
+
accountId === null
|
|
36
|
+
? "# S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com"
|
|
37
|
+
: `S3_ENDPOINT=https://${accountId}.r2.cloudflarestorage.com`,
|
|
38
|
+
...(publicUrl === null ? [] : [`CLICHE_PUBLIC_URL=${publicUrl}`]),
|
|
39
|
+
`# Mint the two keys at ${DASHBOARD_TOKENS_URL} (Object Read & Write on ${bucket}):`,
|
|
40
|
+
"# S3_ACCESS_KEY_ID=",
|
|
41
|
+
"# S3_SECRET_ACCESS_KEY=",
|
|
42
|
+
"",
|
|
43
|
+
];
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
export async function setup(bucket) {
|
|
47
|
+
console.error(`Setting up Cloudflare R2 bucket "${bucket}" via wrangler…`);
|
|
48
|
+
const whoami = await wrangler(["whoami"]);
|
|
49
|
+
if (whoami.exitCode !== 0 || whoami.output.includes("You are not authenticated")) {
|
|
50
|
+
throw new Error("wrangler is not logged in — run `bunx wrangler login` first.");
|
|
51
|
+
}
|
|
52
|
+
const accountId = parseAccountId(whoami.output);
|
|
53
|
+
if (accountId === null) {
|
|
54
|
+
console.error("Several Cloudflare accounts found: wrangler will ask which one to use (set CLOUDFLARE_ACCOUNT_ID to skip the prompt).");
|
|
55
|
+
}
|
|
56
|
+
const create = await wrangler(["r2", "bucket", "create", bucket], { interactive: true });
|
|
57
|
+
if (create.exitCode !== 0) {
|
|
58
|
+
// Most likely the bucket already exists, which is fine for reruns; the
|
|
59
|
+
// dev-url step below fails loudly if the bucket truly is not there.
|
|
60
|
+
console.error(`(bucket create exited ${create.exitCode} — continuing, it probably already exists)`);
|
|
61
|
+
}
|
|
62
|
+
console.error("Enabling the managed public r2.dev URL…");
|
|
63
|
+
await wrangler(["r2", "bucket", "dev-url", "enable", bucket, "--force"], { interactive: true });
|
|
64
|
+
const devUrl = await wrangler(["r2", "bucket", "dev-url", "get", bucket]);
|
|
65
|
+
const publicUrl = parsePublicUrl(devUrl.output);
|
|
66
|
+
const environmentFile = Bun.file(".env");
|
|
67
|
+
const existing = (await environmentFile.exists()) ? await environmentFile.text() : "";
|
|
68
|
+
if (existing.includes("S3_BUCKET=")) {
|
|
69
|
+
console.error(".env already has an S3_BUCKET — printing the block instead of appending:");
|
|
70
|
+
console.log(environmentBlock(bucket, publicUrl, accountId));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
await Bun.write(".env", existing + environmentBlock(bucket, publicUrl, accountId));
|
|
74
|
+
console.error("Wrote the configuration to .env.");
|
|
75
|
+
}
|
|
76
|
+
console.error(`
|
|
77
|
+
Almost there — one last step wrangler cannot do:
|
|
78
|
+
1. Open ${DASHBOARD_TOKENS_URL}
|
|
79
|
+
2. Create an API token with "Object Read & Write" on "${bucket}"
|
|
80
|
+
3. Put the two values in .env as S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
|
|
81
|
+
|
|
82
|
+
Then take your first cliché:
|
|
83
|
+
bunx @voila.dev/cliche https://example.com shot.png --upload`);
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=setup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup.js","sourceRoot":"","sources":["../src/setup.ts"],"names":[],"mappings":"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"}
|
package/dist/skill.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const SKILL_TARGET = ".claude/skills/pr-screenshots/SKILL.md";
|
|
2
|
+
/**
|
|
3
|
+
* Install the packaged PR-screenshots skill into the current repository.
|
|
4
|
+
* The skill ships with the package (`skill/SKILL.md`), so this works offline
|
|
5
|
+
* and always matches the installed cliche version.
|
|
6
|
+
*/
|
|
7
|
+
export declare function installSkill(): Promise<string>;
|
|
8
|
+
//# sourceMappingURL=skill.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill.d.ts","sourceRoot":"","sources":["../src/skill.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,2CAA2C,CAAC;AAErE;;;;GAIG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAIpD"}
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
export const SKILL_TARGET = ".claude/skills/pr-screenshots/SKILL.md";
|
|
3
|
+
/**
|
|
4
|
+
* Install the packaged PR-screenshots skill into the current repository.
|
|
5
|
+
* The skill ships with the package (`skill/SKILL.md`), so this works offline
|
|
6
|
+
* and always matches the installed cliche version.
|
|
7
|
+
*/
|
|
8
|
+
export async function installSkill() {
|
|
9
|
+
const source = fileURLToPath(new URL("../skill/SKILL.md", import.meta.url));
|
|
10
|
+
await Bun.write(SKILL_TARGET, Bun.file(source));
|
|
11
|
+
return SKILL_TARGET;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=skill.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill.js","sourceRoot":"","sources":["../src/skill.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,CAAC,MAAM,YAAY,GAAG,wCAAwC,CAAC;AAErE;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY;IAChC,MAAM,MAAM,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,mBAAmB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5E,MAAM,GAAG,CAAC,KAAK,CAAC,YAAY,EAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;IAChD,OAAO,YAAY,CAAC;AACtB,CAAC"}
|
package/dist/upload.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface UploadOptions {
|
|
2
|
+
readonly files: ReadonlyArray<string>;
|
|
3
|
+
/** Object key prefix, e.g. `pr-123`. Defaults to `cliche`. */
|
|
4
|
+
readonly prefix?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface UploadedFile {
|
|
7
|
+
readonly file: string;
|
|
8
|
+
readonly key: string;
|
|
9
|
+
readonly url: string;
|
|
10
|
+
/** A ready-to-paste `` line. */
|
|
11
|
+
readonly markdown: string;
|
|
12
|
+
}
|
|
13
|
+
/** The subset of Bun.S3Client the upload needs; injectable for tests. */
|
|
14
|
+
export interface ObjectWriter {
|
|
15
|
+
write(key: string, bytes: Uint8Array, options: {
|
|
16
|
+
type: string;
|
|
17
|
+
}): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
export declare function publicBaseUrl(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Upload images to the S3-compatible bucket described by the standard
|
|
22
|
+
* environment variables Bun.S3Client already reads (S3_* / AWS_*), and
|
|
23
|
+
* return one markdown line per file. `CLICHE_PUBLIC_URL` overrides the
|
|
24
|
+
* public base URL (custom domains, R2 public buckets).
|
|
25
|
+
*/
|
|
26
|
+
export declare function upload(options: UploadOptions, writer?: ObjectWriter): Promise<Array<UploadedFile>>;
|
|
27
|
+
//# sourceMappingURL=upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACpF;AAED,wBAAgB,aAAa,IAAI,MAAM,CAatC;AAED;;;;;GAKG;AACH,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,EAAE,MAAM,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAoBxG"}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
import { captionOf, objectKeyOf } from "./keys.js";
|
|
3
|
+
const CONTENT_TYPES = {
|
|
4
|
+
".png": "image/png",
|
|
5
|
+
".jpg": "image/jpeg",
|
|
6
|
+
".jpeg": "image/jpeg",
|
|
7
|
+
".webp": "image/webp",
|
|
8
|
+
".gif": "image/gif",
|
|
9
|
+
};
|
|
10
|
+
export function publicBaseUrl() {
|
|
11
|
+
const configured = process.env.CLICHE_PUBLIC_URL;
|
|
12
|
+
if (configured !== undefined)
|
|
13
|
+
return configured.replace(/\/$/, "");
|
|
14
|
+
const bucket = process.env.S3_BUCKET ?? process.env.AWS_BUCKET;
|
|
15
|
+
if (bucket === undefined) {
|
|
16
|
+
throw new Error("No bucket configured: set S3_BUCKET (and S3_ENDPOINT + S3_ACCESS_KEY_ID + S3_SECRET_ACCESS_KEY), or CLICHE_PUBLIC_URL alone if the bucket is resolved elsewhere.");
|
|
17
|
+
}
|
|
18
|
+
const endpoint = process.env.S3_ENDPOINT ?? process.env.AWS_ENDPOINT;
|
|
19
|
+
if (endpoint !== undefined)
|
|
20
|
+
return `${endpoint.replace(/\/$/, "")}/${bucket}`;
|
|
21
|
+
const region = process.env.AWS_REGION ?? process.env.S3_REGION ?? "us-east-1";
|
|
22
|
+
return `https://${bucket}.s3.${region}.amazonaws.com`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Upload images to the S3-compatible bucket described by the standard
|
|
26
|
+
* environment variables Bun.S3Client already reads (S3_* / AWS_*), and
|
|
27
|
+
* return one markdown line per file. `CLICHE_PUBLIC_URL` overrides the
|
|
28
|
+
* public base URL (custom domains, R2 public buckets).
|
|
29
|
+
*/
|
|
30
|
+
export async function upload(options, writer) {
|
|
31
|
+
const baseUrl = publicBaseUrl();
|
|
32
|
+
const client = writer ?? new Bun.S3Client();
|
|
33
|
+
const prefix = options.prefix ?? "cliche";
|
|
34
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
35
|
+
const uploaded = [];
|
|
36
|
+
for (const file of options.files) {
|
|
37
|
+
const contentType = CONTENT_TYPES[extname(file).toLowerCase()];
|
|
38
|
+
if (contentType === undefined) {
|
|
39
|
+
console.error(`Skipping ${file}: unsupported extension`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const bytes = await Bun.file(file).bytes();
|
|
43
|
+
const key = objectKeyOf(prefix, file, bytes, date);
|
|
44
|
+
await client.write(key, bytes, { type: contentType });
|
|
45
|
+
console.error(`Uploaded ${file} -> ${key}`);
|
|
46
|
+
const url = `${baseUrl}/${key}`;
|
|
47
|
+
uploaded.push({ file, key, url, markdown: `` });
|
|
48
|
+
}
|
|
49
|
+
return uploaded;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=upload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload.js","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAEnD,MAAM,aAAa,GAA2B;IAC5C,MAAM,EAAE,WAAW;IACnB,MAAM,EAAE,YAAY;IACpB,OAAO,EAAE,YAAY;IACrB,OAAO,EAAE,YAAY;IACrB,MAAM,EAAE,WAAW;CACpB,CAAC;AAqBF,MAAM,UAAU,aAAa;IAC3B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;IACjD,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,UAAU,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACnE,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC;IAC/D,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CACb,kKAAkK,CACnK,CAAC;IACJ,CAAC;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC;IACrE,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,GAAG,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,MAAM,EAAE,CAAC;IAC9E,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,WAAW,CAAC;IAC9E,OAAO,WAAW,MAAM,OAAO,MAAM,gBAAgB,CAAC;AACxD,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,MAAM,CAAC,OAAsB,EAAE,MAAqB;IACxE,MAAM,OAAO,GAAG,aAAa,EAAE,CAAC;IAChC,MAAM,MAAM,GAAG,MAAM,IAAI,IAAI,GAAG,CAAC,QAAQ,EAAE,CAAC;IAC5C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,QAAQ,CAAC;IAC1C,MAAM,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACnD,MAAM,QAAQ,GAAwB,EAAE,CAAC;IACzC,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACjC,MAAM,WAAW,GAAG,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/D,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;YAC9B,OAAO,CAAC,KAAK,CAAC,YAAY,IAAI,yBAAyB,CAAC,CAAC;YACzD,SAAS;QACX,CAAC;QACD,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC;QAC3C,MAAM,GAAG,GAAG,WAAW,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC;QACnD,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;QACtD,OAAO,CAAC,KAAK,CAAC,YAAY,IAAI,OAAO,GAAG,EAAE,CAAC,CAAC;QAC5C,MAAM,GAAG,GAAG,GAAG,OAAO,IAAI,GAAG,EAAE,CAAC;QAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,SAAS,CAAC,IAAI,CAAC,KAAK,GAAG,GAAG,EAAE,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voila.dev/cliche",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"packageManager": "bun@1.4.0",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "Take a cliché of any page and get a shareable URL — screenshots with Bun.WebView, uploads with Bun.S3Client, zero dependencies.",
|
|
8
|
+
"homepage": "https://github.com/voila-voila-dev/cliche",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/voila-voila-dev/cliche.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/voila-voila-dev/cliche/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"screenshot",
|
|
18
|
+
"bun",
|
|
19
|
+
"webview",
|
|
20
|
+
"s3",
|
|
21
|
+
"r2",
|
|
22
|
+
"pull-request",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": [],
|
|
26
|
+
"bin": {
|
|
27
|
+
"cliche": "./dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
"./package.json": "./package.json",
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"skill",
|
|
40
|
+
"apps/album/src",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"bun": ">=1.4.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.build.json",
|
|
49
|
+
"check": "tsc -p tsconfig.json",
|
|
50
|
+
"test": "bun test",
|
|
51
|
+
"ci": "bun run check && bun run build && bun test"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/bun": "^1.4.0",
|
|
55
|
+
"typescript": "^5.9.3"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-screenshots
|
|
3
|
+
description: Capture before/after screenshots of UI changes with @voila.dev/cliche and embed them in the pull request. Use whenever a PR touches something user-visible, right before opening or updating the PR. Also on request.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PR screenshots
|
|
7
|
+
|
|
8
|
+
A PR that changes what users see should show it. Capture the changed screens
|
|
9
|
+
with `cliche`, upload them, and embed the markdown in the PR body.
|
|
10
|
+
|
|
11
|
+
## When (and when not)
|
|
12
|
+
|
|
13
|
+
- Any PR whose diff changes rendered UI: screens, components, layout, states,
|
|
14
|
+
copy with visual impact. Skip for pure logic/backend/refactor PRs where
|
|
15
|
+
pixels are provably identical.
|
|
16
|
+
- Capture **after** shots always. Add **before** shots (production or a
|
|
17
|
+
deployed preview) only when the change modifies an existing screen: the
|
|
18
|
+
pair is what makes a redesign reviewable.
|
|
19
|
+
|
|
20
|
+
## 1. Capture
|
|
21
|
+
|
|
22
|
+
Run the branch locally, then:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bunx @voila.dev/cliche http://localhost:3000/changed-screen \
|
|
26
|
+
qa-screenshots/changed-screen-after.png \
|
|
27
|
+
--wait-for '[data-testid=changed-screen]'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `--local-storage <key>=<token>` seeds a session token for authenticated
|
|
31
|
+
screens; `--scroll-to <css>` reaches below-the-fold components;
|
|
32
|
+
`--viewport 390x844` for responsive changes (default is 1440x900);
|
|
33
|
+
`--full-page` when the whole screen changed.
|
|
34
|
+
- Use demo/seed data only. Never real user data: the bucket is public.
|
|
35
|
+
- Name files as captions: `mission-detail-after.png` reads as
|
|
36
|
+
"mission detail after".
|
|
37
|
+
- Keep it curated: at most ~8 images per PR, one per meaningfully distinct
|
|
38
|
+
screen/state.
|
|
39
|
+
|
|
40
|
+
## 2. Upload
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
bunx @voila.dev/cliche upload --prefix pr-<number> --markdown qa-screenshots/*.png
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Prints one `` line per file on stdout (without `--markdown`,
|
|
47
|
+
bare URLs). Bucket configuration
|
|
48
|
+
comes from the standard `S3_*` environment variables plus
|
|
49
|
+
`CLICHE_PUBLIC_URL` (see the package README).
|
|
50
|
+
|
|
51
|
+
## 3. Embed in the PR
|
|
52
|
+
|
|
53
|
+
Add a `## Screenshots` section to the PR body (`gh pr edit <n> --body ...`):
|
|
54
|
+
|
|
55
|
+
```markdown
|
|
56
|
+
## Screenshots
|
|
57
|
+
|
|
58
|
+
| Before | After |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
|  |  |
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
After-only shots go as plain images with a one-line caption above each. When
|
|
64
|
+
updating after review changes: re-capture, re-upload (keys are
|
|
65
|
+
content-hashed, old links keep working), replace the section.
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const WAIT_FOR_TIMEOUT_MILLISECONDS = 15_000;
|
|
2
|
+
const WAIT_FOR_POLL_MILLISECONDS = 250;
|
|
3
|
+
|
|
4
|
+
export interface Viewport {
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CaptureOptions {
|
|
10
|
+
/** The page to screenshot. */
|
|
11
|
+
readonly url: string;
|
|
12
|
+
/** Where to write the PNG. */
|
|
13
|
+
readonly out: string;
|
|
14
|
+
/** Defaults to 1440×900. */
|
|
15
|
+
readonly viewport?: Viewport;
|
|
16
|
+
/**
|
|
17
|
+
* Entries seeded into the target origin's localStorage before the page
|
|
18
|
+
* loads — the way in for apps that keep their session token there.
|
|
19
|
+
*/
|
|
20
|
+
readonly localStorage?: Readonly<Record<string, string>>;
|
|
21
|
+
/** CSS selector to wait for before shooting (15s timeout). */
|
|
22
|
+
readonly waitFor?: string;
|
|
23
|
+
/** CSS selector scrolled into view before shooting. */
|
|
24
|
+
readonly scrollTo?: string;
|
|
25
|
+
/** Milliseconds to let the page settle after load. Defaults to 1500. */
|
|
26
|
+
readonly settleMilliseconds?: number;
|
|
27
|
+
/** Grow the viewport to the full page height before shooting. */
|
|
28
|
+
readonly fullPage?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Bun.WebView rejects viewport dimensions above 16384 pixels.
|
|
32
|
+
const MAXIMUM_VIEWPORT_HEIGHT = 16_384;
|
|
33
|
+
|
|
34
|
+
function sleep(milliseconds: number): Promise<void> {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function waitForSelector(view: Bun.WebView, selector: string): Promise<void> {
|
|
39
|
+
const deadline = Date.now() + WAIT_FOR_TIMEOUT_MILLISECONDS;
|
|
40
|
+
const probe = `!!document.querySelector(${JSON.stringify(selector)})`;
|
|
41
|
+
while (!(await view.evaluate<boolean>(probe))) {
|
|
42
|
+
if (Date.now() > deadline) {
|
|
43
|
+
throw new Error(`Timed out after ${WAIT_FOR_TIMEOUT_MILLISECONDS}ms waiting for ${selector}`);
|
|
44
|
+
}
|
|
45
|
+
await sleep(WAIT_FOR_POLL_MILLISECONDS);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Screenshot a page with Bun.WebView and write it to `options.out` as PNG. */
|
|
50
|
+
export async function capture(options: CaptureOptions): Promise<void> {
|
|
51
|
+
const viewport = options.viewport ?? { width: 1440, height: 900 };
|
|
52
|
+
await using view = new Bun.WebView(viewport);
|
|
53
|
+
const localStorageEntries = Object.entries(options.localStorage ?? {});
|
|
54
|
+
if (localStorageEntries.length > 0) {
|
|
55
|
+
// localStorage is origin-scoped, so land on the origin (any page, a login
|
|
56
|
+
// redirect is fine) to seed the entries before loading the target.
|
|
57
|
+
await view.navigate(new URL(options.url).origin);
|
|
58
|
+
for (const [key, value] of localStorageEntries) {
|
|
59
|
+
await view.evaluate(
|
|
60
|
+
`localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await view.navigate(options.url);
|
|
65
|
+
if (options.waitFor !== undefined) {
|
|
66
|
+
await waitForSelector(view, options.waitFor);
|
|
67
|
+
}
|
|
68
|
+
// A fixed delay for fonts, images and entrance animations: dev servers keep
|
|
69
|
+
// an HMR socket open, so there is no network-idle moment to wait for.
|
|
70
|
+
await sleep(options.settleMilliseconds ?? 1500);
|
|
71
|
+
if (options.fullPage === true) {
|
|
72
|
+
const pageHeight = await view.evaluate<number>(
|
|
73
|
+
"Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)",
|
|
74
|
+
);
|
|
75
|
+
if (pageHeight > viewport.height) {
|
|
76
|
+
await view.resize(viewport.width, Math.min(pageHeight, MAXIMUM_VIEWPORT_HEIGHT));
|
|
77
|
+
await sleep(400);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (options.scrollTo !== undefined) {
|
|
81
|
+
await view.scrollTo(options.scrollTo);
|
|
82
|
+
await sleep(300);
|
|
83
|
+
}
|
|
84
|
+
await Bun.write(options.out, await view.screenshot());
|
|
85
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { capture } from "./capture.ts";
|
|
3
|
+
import { runMcpServer } from "./mcp.ts";
|
|
4
|
+
import { parseCommand } from "./options.ts";
|
|
5
|
+
import { setup } from "./setup.ts";
|
|
6
|
+
import { installSkill } from "./skill.ts";
|
|
7
|
+
import { upload, type UploadedFile } from "./upload.ts";
|
|
8
|
+
|
|
9
|
+
const HELP = `cliche — take a cliché of any page and get a shareable URL.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
cliche <url> <out.png> [options] Screenshot a page.
|
|
13
|
+
cliche <url> <out.png> --upload …and upload it, printing its URL.
|
|
14
|
+
cliche upload [--prefix pr-123] <files> Upload images, printing their URLs.
|
|
15
|
+
cliche mcp Serve the tools over MCP (stdio).
|
|
16
|
+
cliche setup [--bucket name] Create an R2 bucket via wrangler and
|
|
17
|
+
write the S3 config to .env.
|
|
18
|
+
cliche album [--port 4949] Browse the bucket as a photo album.
|
|
19
|
+
cliche skill Install the PR-screenshots skill into
|
|
20
|
+
.claude/skills/ (Claude Code).
|
|
21
|
+
|
|
22
|
+
Capture options:
|
|
23
|
+
--viewport <WxH> Viewport, default 1440x900 (390x844 for mobile).
|
|
24
|
+
--wait-for <css> Wait for a selector before shooting (15s max).
|
|
25
|
+
--scroll-to <css> Scroll a selector into view before shooting.
|
|
26
|
+
--settle <ms> Let the page settle after load, default 1500.
|
|
27
|
+
--full-page Grow the viewport to the full page height.
|
|
28
|
+
--local-storage key=value Seed localStorage on the target origin
|
|
29
|
+
(repeatable; e.g. a session token).
|
|
30
|
+
|
|
31
|
+
Upload options:
|
|
32
|
+
--prefix <name> Object key prefix, e.g. pr-123.
|
|
33
|
+
--markdown Print  lines instead of URLs.
|
|
34
|
+
|
|
35
|
+
Upload configuration (standard Bun.S3Client environment variables):
|
|
36
|
+
S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY (or AWS_*)
|
|
37
|
+
CLICHE_PUBLIC_URL Public base URL of the bucket (custom domain).
|
|
38
|
+
|
|
39
|
+
URLs go to stdout, progress to stderr. Ouistiti !`;
|
|
40
|
+
|
|
41
|
+
function printUploads(uploaded: ReadonlyArray<UploadedFile>, markdown: boolean): void {
|
|
42
|
+
console.log(uploaded.map((entry) => (markdown ? entry.markdown : entry.url)).join("\n"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main(): Promise<void> {
|
|
46
|
+
const command = parseCommand(Bun.argv.slice(2));
|
|
47
|
+
switch (command.kind) {
|
|
48
|
+
case "help":
|
|
49
|
+
console.log(HELP);
|
|
50
|
+
return;
|
|
51
|
+
case "mcp":
|
|
52
|
+
await runMcpServer();
|
|
53
|
+
return;
|
|
54
|
+
case "skill": {
|
|
55
|
+
const target = await installSkill();
|
|
56
|
+
console.error(`Installed the PR-screenshots skill at ${target}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
case "setup":
|
|
60
|
+
await setup(command.bucket);
|
|
61
|
+
return;
|
|
62
|
+
case "album": {
|
|
63
|
+
if (command.port !== undefined) process.env.PORT = String(command.port);
|
|
64
|
+
// Dynamic with a computed specifier so tsc ships it untouched: the album
|
|
65
|
+
// is TS + HTML imports that Bun resolves at runtime from the package.
|
|
66
|
+
const albumEntry = "../apps/album/src/index.ts";
|
|
67
|
+
await import(albumEntry);
|
|
68
|
+
const url = `http://localhost:${process.env.PORT ?? 4949}`;
|
|
69
|
+
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
70
|
+
Bun.spawn([opener, url], { stdout: "ignore", stderr: "ignore" });
|
|
71
|
+
// Keep the process alive for the server.
|
|
72
|
+
await new Promise(() => {});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
case "upload": {
|
|
76
|
+
const uploaded = await upload({
|
|
77
|
+
files: command.files,
|
|
78
|
+
...(command.prefix === undefined ? {} : { prefix: command.prefix }),
|
|
79
|
+
});
|
|
80
|
+
printUploads(uploaded, command.markdown);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
case "capture": {
|
|
84
|
+
await capture(command.capture);
|
|
85
|
+
console.error(`Captured ${command.capture.url} -> ${command.capture.out}`);
|
|
86
|
+
if (command.upload) {
|
|
87
|
+
const uploaded = await upload({
|
|
88
|
+
files: [command.capture.out],
|
|
89
|
+
...(command.prefix === undefined ? {} : { prefix: command.prefix }),
|
|
90
|
+
});
|
|
91
|
+
printUploads(uploaded, command.markdown);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch((error: unknown) => {
|
|
99
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { capture } from "./capture.ts";
|
|
2
|
+
export type { CaptureOptions, Viewport } from "./capture.ts";
|
|
3
|
+
export { upload } from "./upload.ts";
|
|
4
|
+
export type { ObjectWriter, UploadedFile, UploadOptions } from "./upload.ts";
|
|
5
|
+
export { captionOf, objectKeyOf, slugOf } from "./keys.ts";
|
|
6
|
+
export { handleMessage, runMcpServer } from "./mcp.ts";
|
|
7
|
+
export type { McpDependencies } from "./mcp.ts";
|