@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/src/keys.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
|
|
3
|
+
/** S3 object keys only tolerate a narrow character set. */
|
|
4
|
+
export function slugOf(rawPrefix: string): string {
|
|
5
|
+
return rawPrefix.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The image's caption in the generated markdown. */
|
|
9
|
+
export function captionOf(file: string): string {
|
|
10
|
+
return basename(file, extname(file)).replace(/[-_]+/g, " ");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function contentHash(bytes: Uint8Array): string {
|
|
14
|
+
return new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 8);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `<prefix>/<yyyy-mm-dd>-<basename>-<content-hash>.<ext>`: the hash makes
|
|
19
|
+
* re-uploads cache-safe, the date keeps the bucket browsable.
|
|
20
|
+
*/
|
|
21
|
+
export function objectKeyOf(prefix: string, file: string, bytes: Uint8Array, date: string): string {
|
|
22
|
+
const extension = extname(file);
|
|
23
|
+
return `${slugOf(prefix)}/${date}-${basename(file, extension)}-${contentHash(bytes)}${extension.toLowerCase()}`;
|
|
24
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { capture as captureImplementation, type CaptureOptions } from "./capture.ts";
|
|
4
|
+
import { parseLocalStorage, parseViewport } from "./options.ts";
|
|
5
|
+
import { upload as uploadImplementation, type UploadedFile, type UploadOptions } from "./upload.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A hand-rolled MCP server: the stdio transport is newline-delimited JSON-RPC
|
|
9
|
+
* 2.0, and this server only needs initialize + tools, so the protocol fits in
|
|
10
|
+
* this file and the package stays zero-dependency.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
14
|
+
|
|
15
|
+
interface JsonRpcMessage {
|
|
16
|
+
readonly jsonrpc: "2.0";
|
|
17
|
+
readonly id?: number | string;
|
|
18
|
+
readonly method?: string;
|
|
19
|
+
readonly params?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The implementations, injectable for tests. */
|
|
23
|
+
export interface McpDependencies {
|
|
24
|
+
readonly capture: (options: CaptureOptions) => Promise<void>;
|
|
25
|
+
readonly upload: (options: UploadOptions) => Promise<Array<UploadedFile>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: "screenshot",
|
|
31
|
+
description:
|
|
32
|
+
"Screenshot a web page locally with Bun.WebView (no browser install). Optionally upload it to the configured S3-compatible bucket and return its public URL.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
url: { type: "string", description: "The page to screenshot." },
|
|
37
|
+
out: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "Where to write the PNG. Defaults to a temporary file.",
|
|
40
|
+
},
|
|
41
|
+
viewport: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Viewport as <width>x<height>. Defaults to 1440x900 (use 390x844 for mobile).",
|
|
44
|
+
},
|
|
45
|
+
wait_for: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "CSS selector to wait for before shooting (15s timeout).",
|
|
48
|
+
},
|
|
49
|
+
scroll_to: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "CSS selector scrolled into view before shooting.",
|
|
52
|
+
},
|
|
53
|
+
settle_ms: {
|
|
54
|
+
type: "number",
|
|
55
|
+
description: "Milliseconds to let the page settle after load. Defaults to 1500.",
|
|
56
|
+
},
|
|
57
|
+
full_page: {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
description: "Grow the viewport to the full page height before shooting.",
|
|
60
|
+
},
|
|
61
|
+
local_storage: {
|
|
62
|
+
type: "object",
|
|
63
|
+
additionalProperties: { type: "string" },
|
|
64
|
+
description:
|
|
65
|
+
"Entries seeded into the target origin's localStorage before the page loads (e.g. a session token for authenticated screens).",
|
|
66
|
+
},
|
|
67
|
+
upload: {
|
|
68
|
+
type: "boolean",
|
|
69
|
+
description: "Upload the shot and return its public URL.",
|
|
70
|
+
},
|
|
71
|
+
prefix: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Object key prefix for the upload, e.g. pr-123.",
|
|
74
|
+
},
|
|
75
|
+
markdown: {
|
|
76
|
+
type: "boolean",
|
|
77
|
+
description: "Return a  markdown line instead of the bare URL.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ["url"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "upload",
|
|
85
|
+
description:
|
|
86
|
+
"Upload local images to the configured S3-compatible bucket and return their public URLs (content-hashed keys, safe to re-upload).",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
files: {
|
|
91
|
+
type: "array",
|
|
92
|
+
items: { type: "string" },
|
|
93
|
+
description: "Paths of the images to upload.",
|
|
94
|
+
},
|
|
95
|
+
prefix: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "Object key prefix, e.g. pr-123.",
|
|
98
|
+
},
|
|
99
|
+
markdown: {
|
|
100
|
+
type: "boolean",
|
|
101
|
+
description: "Return  markdown lines instead of the bare URLs.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
required: ["files"],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
function textResult(text: string, isError = false): Record<string, unknown> {
|
|
110
|
+
return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderUploads(uploaded: ReadonlyArray<UploadedFile>, markdown: boolean): string {
|
|
114
|
+
return uploaded.map((entry) => (markdown ? entry.markdown : entry.url)).join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function temporaryOut(): string {
|
|
118
|
+
return join(tmpdir(), `cliche-${Date.now().toString(36)}.png`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function callTool(
|
|
122
|
+
name: string,
|
|
123
|
+
input: Record<string, unknown>,
|
|
124
|
+
dependencies: McpDependencies,
|
|
125
|
+
): Promise<Record<string, unknown>> {
|
|
126
|
+
if (name === "screenshot") {
|
|
127
|
+
const out = typeof input.out === "string" ? input.out : temporaryOut();
|
|
128
|
+
await dependencies.capture({
|
|
129
|
+
url: String(input.url),
|
|
130
|
+
out,
|
|
131
|
+
...(typeof input.viewport === "string" ? { viewport: parseViewport(input.viewport) } : {}),
|
|
132
|
+
...(typeof input.wait_for === "string" ? { waitFor: input.wait_for } : {}),
|
|
133
|
+
...(typeof input.scroll_to === "string" ? { scrollTo: input.scroll_to } : {}),
|
|
134
|
+
...(typeof input.settle_ms === "number" ? { settleMilliseconds: input.settle_ms } : {}),
|
|
135
|
+
...(input.full_page === true ? { fullPage: true } : {}),
|
|
136
|
+
...(input.local_storage !== undefined && typeof input.local_storage === "object"
|
|
137
|
+
? { localStorage: input.local_storage as Record<string, string> }
|
|
138
|
+
: {}),
|
|
139
|
+
});
|
|
140
|
+
if (input.upload !== true) {
|
|
141
|
+
return textResult(`Captured ${String(input.url)} -> ${out}`);
|
|
142
|
+
}
|
|
143
|
+
const uploaded = await dependencies.upload({
|
|
144
|
+
files: [out],
|
|
145
|
+
...(typeof input.prefix === "string" ? { prefix: input.prefix } : {}),
|
|
146
|
+
});
|
|
147
|
+
return textResult(renderUploads(uploaded, input.markdown === true));
|
|
148
|
+
}
|
|
149
|
+
if (name === "upload") {
|
|
150
|
+
const uploaded = await dependencies.upload({
|
|
151
|
+
files: (input.files as Array<string>) ?? [],
|
|
152
|
+
...(typeof input.prefix === "string" ? { prefix: input.prefix } : {}),
|
|
153
|
+
});
|
|
154
|
+
return textResult(renderUploads(uploaded, input.markdown === true));
|
|
155
|
+
}
|
|
156
|
+
return textResult(`Unknown tool: ${name}`, true);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Handle one JSON-RPC message; null means nothing to send back. */
|
|
160
|
+
export async function handleMessage(
|
|
161
|
+
message: JsonRpcMessage,
|
|
162
|
+
dependencies: McpDependencies,
|
|
163
|
+
): Promise<Record<string, unknown> | null> {
|
|
164
|
+
// Notifications carry no id and expect no response.
|
|
165
|
+
if (message.id === undefined) return null;
|
|
166
|
+
const reply = (result: Record<string, unknown>) => ({
|
|
167
|
+
jsonrpc: "2.0",
|
|
168
|
+
id: message.id,
|
|
169
|
+
result,
|
|
170
|
+
});
|
|
171
|
+
switch (message.method) {
|
|
172
|
+
case "initialize":
|
|
173
|
+
return reply({
|
|
174
|
+
protocolVersion:
|
|
175
|
+
typeof message.params?.protocolVersion === "string"
|
|
176
|
+
? message.params.protocolVersion
|
|
177
|
+
: PROTOCOL_VERSION,
|
|
178
|
+
capabilities: { tools: {} },
|
|
179
|
+
serverInfo: { name: "cliche", version: "0.1.0" },
|
|
180
|
+
});
|
|
181
|
+
case "ping":
|
|
182
|
+
return reply({});
|
|
183
|
+
case "tools/list":
|
|
184
|
+
return reply({ tools: TOOLS });
|
|
185
|
+
case "tools/call": {
|
|
186
|
+
const name = String(message.params?.name);
|
|
187
|
+
const input = (message.params?.arguments ?? {}) as Record<string, unknown>;
|
|
188
|
+
try {
|
|
189
|
+
return reply(await callTool(name, input, dependencies));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return reply(textResult(error instanceof Error ? error.message : String(error), true));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
default:
|
|
195
|
+
return {
|
|
196
|
+
jsonrpc: "2.0",
|
|
197
|
+
id: message.id,
|
|
198
|
+
error: { code: -32601, message: `Method not found: ${message.method}` },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Serve MCP over stdio until stdin closes. */
|
|
204
|
+
export async function runMcpServer(): Promise<void> {
|
|
205
|
+
const dependencies: McpDependencies = {
|
|
206
|
+
capture: captureImplementation,
|
|
207
|
+
upload: uploadImplementation,
|
|
208
|
+
};
|
|
209
|
+
for await (const line of console) {
|
|
210
|
+
if (line.trim() === "") continue;
|
|
211
|
+
let message: JsonRpcMessage;
|
|
212
|
+
try {
|
|
213
|
+
message = JSON.parse(line) as JsonRpcMessage;
|
|
214
|
+
} catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const response = await handleMessage(message, dependencies);
|
|
218
|
+
if (response !== null) console.log(JSON.stringify(response));
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import type { CaptureOptions, Viewport } from "./capture.ts";
|
|
3
|
+
|
|
4
|
+
export interface CaptureCommand {
|
|
5
|
+
readonly kind: "capture";
|
|
6
|
+
readonly capture: CaptureOptions;
|
|
7
|
+
/** Upload the shot right after capturing it. */
|
|
8
|
+
readonly upload: boolean;
|
|
9
|
+
readonly prefix: string | undefined;
|
|
10
|
+
/** Print `` lines instead of the bare URLs. */
|
|
11
|
+
readonly markdown: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UploadCommand {
|
|
15
|
+
readonly kind: "upload";
|
|
16
|
+
readonly files: ReadonlyArray<string>;
|
|
17
|
+
readonly prefix: string | undefined;
|
|
18
|
+
readonly markdown: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpCommand {
|
|
22
|
+
readonly kind: "mcp";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SkillCommand {
|
|
26
|
+
readonly kind: "skill";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SetupCommand {
|
|
30
|
+
readonly kind: "setup";
|
|
31
|
+
readonly bucket: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AlbumCommand {
|
|
35
|
+
readonly kind: "album";
|
|
36
|
+
readonly port: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HelpCommand {
|
|
40
|
+
readonly kind: "help";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type Command =
|
|
44
|
+
| CaptureCommand
|
|
45
|
+
| UploadCommand
|
|
46
|
+
| McpCommand
|
|
47
|
+
| SkillCommand
|
|
48
|
+
| SetupCommand
|
|
49
|
+
| AlbumCommand
|
|
50
|
+
| HelpCommand;
|
|
51
|
+
|
|
52
|
+
export function parseViewport(value: string): Viewport {
|
|
53
|
+
const match = value.match(/^(\d+)x(\d+)$/);
|
|
54
|
+
if (match === null) {
|
|
55
|
+
throw new Error(`Invalid --viewport ${value}: expected <width>x<height>, e.g. 1440x900.`);
|
|
56
|
+
}
|
|
57
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseLocalStorage(entries: ReadonlyArray<string>): Record<string, string> {
|
|
61
|
+
const parsed: Record<string, string> = {};
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const separator = entry.indexOf("=");
|
|
64
|
+
if (separator < 1) {
|
|
65
|
+
throw new Error(`Invalid --local-storage ${entry}: expected key=value.`);
|
|
66
|
+
}
|
|
67
|
+
parsed[entry.slice(0, separator)] = entry.slice(separator + 1);
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parseCommand(argv: ReadonlyArray<string>): Command {
|
|
73
|
+
const { values, positionals } = parseArgs({
|
|
74
|
+
args: [...argv],
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
options: {
|
|
77
|
+
viewport: { type: "string" },
|
|
78
|
+
"wait-for": { type: "string" },
|
|
79
|
+
"scroll-to": { type: "string" },
|
|
80
|
+
settle: { type: "string" },
|
|
81
|
+
"local-storage": { type: "string", multiple: true },
|
|
82
|
+
"full-page": { type: "boolean" },
|
|
83
|
+
upload: { type: "boolean" },
|
|
84
|
+
prefix: { type: "string" },
|
|
85
|
+
markdown: { type: "boolean" },
|
|
86
|
+
bucket: { type: "string" },
|
|
87
|
+
port: { type: "string" },
|
|
88
|
+
help: { type: "boolean", short: "h" },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (values.help === true || positionals.length === 0) {
|
|
92
|
+
return { kind: "help" };
|
|
93
|
+
}
|
|
94
|
+
if (positionals[0] === "mcp") {
|
|
95
|
+
return { kind: "mcp" };
|
|
96
|
+
}
|
|
97
|
+
if (positionals[0] === "skill") {
|
|
98
|
+
return { kind: "skill" };
|
|
99
|
+
}
|
|
100
|
+
if (positionals[0] === "setup") {
|
|
101
|
+
return { kind: "setup", bucket: values.bucket ?? "cliche-shots" };
|
|
102
|
+
}
|
|
103
|
+
if (positionals[0] === "album") {
|
|
104
|
+
const port = values.port === undefined ? undefined : Number(values.port);
|
|
105
|
+
if (port !== undefined && !Number.isInteger(port)) {
|
|
106
|
+
throw new Error(`Invalid --port ${values.port}: expected a number.`);
|
|
107
|
+
}
|
|
108
|
+
return { kind: "album", port };
|
|
109
|
+
}
|
|
110
|
+
if (positionals[0] === "upload") {
|
|
111
|
+
const files = positionals.slice(1);
|
|
112
|
+
if (files.length === 0) throw new Error("upload: pass at least one image file.");
|
|
113
|
+
return { kind: "upload", files, prefix: values.prefix, markdown: values.markdown === true };
|
|
114
|
+
}
|
|
115
|
+
const [url, out] = positionals;
|
|
116
|
+
if (url === undefined || out === undefined) {
|
|
117
|
+
throw new Error("capture: pass <url> <out.png> (or see --help).");
|
|
118
|
+
}
|
|
119
|
+
const settle = values.settle === undefined ? undefined : Number(values.settle);
|
|
120
|
+
if (settle !== undefined && !Number.isFinite(settle)) {
|
|
121
|
+
throw new Error(`Invalid --settle ${values.settle}: expected milliseconds.`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
kind: "capture",
|
|
125
|
+
capture: {
|
|
126
|
+
url,
|
|
127
|
+
out,
|
|
128
|
+
...(values.viewport === undefined ? {} : { viewport: parseViewport(values.viewport) }),
|
|
129
|
+
...(values["wait-for"] === undefined ? {} : { waitFor: values["wait-for"] }),
|
|
130
|
+
...(values["scroll-to"] === undefined ? {} : { scrollTo: values["scroll-to"] }),
|
|
131
|
+
...(settle === undefined ? {} : { settleMilliseconds: settle }),
|
|
132
|
+
...(values["local-storage"] === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: { localStorage: parseLocalStorage(values["local-storage"]) }),
|
|
135
|
+
...(values["full-page"] === true ? { fullPage: true } : {}),
|
|
136
|
+
},
|
|
137
|
+
upload: values.upload === true,
|
|
138
|
+
prefix: values.prefix,
|
|
139
|
+
markdown: values.markdown === true,
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
|
|
9
|
+
const DASHBOARD_TOKENS_URL = "https://dash.cloudflare.com/?to=/:account/r2/api-tokens";
|
|
10
|
+
|
|
11
|
+
async function wrangler(
|
|
12
|
+
args: Array<string>,
|
|
13
|
+
options: { interactive?: boolean } = {},
|
|
14
|
+
): Promise<{ exitCode: number; output: string }> {
|
|
15
|
+
const subprocess = Bun.spawn(["bunx", "wrangler", ...args], {
|
|
16
|
+
stdin: "inherit",
|
|
17
|
+
stdout: options.interactive === true ? "inherit" : "pipe",
|
|
18
|
+
stderr: options.interactive === true ? "inherit" : "pipe",
|
|
19
|
+
});
|
|
20
|
+
const exitCode = await subprocess.exited;
|
|
21
|
+
const output =
|
|
22
|
+
options.interactive === true
|
|
23
|
+
? ""
|
|
24
|
+
: (await new Response(subprocess.stdout).text()) +
|
|
25
|
+
(await new Response(subprocess.stderr).text());
|
|
26
|
+
return { exitCode, output };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseAccountId(whoamiOutput: string): string | null {
|
|
30
|
+
// `wrangler whoami` prints an account table; a lone account is unambiguous.
|
|
31
|
+
const ids = [...new Set(whoamiOutput.match(/\b[0-9a-f]{32}\b/g) ?? [])];
|
|
32
|
+
return ids.length === 1 ? (ids[0] ?? null) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parsePublicUrl(devUrlOutput: string): string | null {
|
|
36
|
+
return devUrlOutput.match(/https:\/\/[a-z0-9-]+\.r2\.dev/)?.[0] ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function environmentBlock(
|
|
40
|
+
bucket: string,
|
|
41
|
+
publicUrl: string | null,
|
|
42
|
+
accountId: string | null,
|
|
43
|
+
): string {
|
|
44
|
+
const lines = [
|
|
45
|
+
"",
|
|
46
|
+
"# cliche — added by `cliche setup` (https://cliche.voila.dev)",
|
|
47
|
+
`S3_BUCKET=${bucket}`,
|
|
48
|
+
accountId === null
|
|
49
|
+
? "# S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com"
|
|
50
|
+
: `S3_ENDPOINT=https://${accountId}.r2.cloudflarestorage.com`,
|
|
51
|
+
...(publicUrl === null ? [] : [`CLICHE_PUBLIC_URL=${publicUrl}`]),
|
|
52
|
+
`# Mint the two keys at ${DASHBOARD_TOKENS_URL} (Object Read & Write on ${bucket}):`,
|
|
53
|
+
"# S3_ACCESS_KEY_ID=",
|
|
54
|
+
"# S3_SECRET_ACCESS_KEY=",
|
|
55
|
+
"",
|
|
56
|
+
];
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function setup(bucket: string): Promise<void> {
|
|
61
|
+
console.error(`Setting up Cloudflare R2 bucket "${bucket}" via wrangler…`);
|
|
62
|
+
|
|
63
|
+
const whoami = await wrangler(["whoami"]);
|
|
64
|
+
if (whoami.exitCode !== 0 || whoami.output.includes("You are not authenticated")) {
|
|
65
|
+
throw new Error("wrangler is not logged in — run `bunx wrangler login` first.");
|
|
66
|
+
}
|
|
67
|
+
const accountId = parseAccountId(whoami.output);
|
|
68
|
+
if (accountId === null) {
|
|
69
|
+
console.error(
|
|
70
|
+
"Several Cloudflare accounts found: wrangler will ask which one to use (set CLOUDFLARE_ACCOUNT_ID to skip the prompt).",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const create = await wrangler(["r2", "bucket", "create", bucket], { interactive: true });
|
|
75
|
+
if (create.exitCode !== 0) {
|
|
76
|
+
// Most likely the bucket already exists, which is fine for reruns; the
|
|
77
|
+
// dev-url step below fails loudly if the bucket truly is not there.
|
|
78
|
+
console.error(`(bucket create exited ${create.exitCode} — continuing, it probably already exists)`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.error("Enabling the managed public r2.dev URL…");
|
|
82
|
+
await wrangler(["r2", "bucket", "dev-url", "enable", bucket, "--force"], { interactive: true });
|
|
83
|
+
const devUrl = await wrangler(["r2", "bucket", "dev-url", "get", bucket]);
|
|
84
|
+
const publicUrl = parsePublicUrl(devUrl.output);
|
|
85
|
+
|
|
86
|
+
const environmentFile = Bun.file(".env");
|
|
87
|
+
const existing = (await environmentFile.exists()) ? await environmentFile.text() : "";
|
|
88
|
+
if (existing.includes("S3_BUCKET=")) {
|
|
89
|
+
console.error(".env already has an S3_BUCKET — printing the block instead of appending:");
|
|
90
|
+
console.log(environmentBlock(bucket, publicUrl, accountId));
|
|
91
|
+
} else {
|
|
92
|
+
await Bun.write(".env", existing + environmentBlock(bucket, publicUrl, accountId));
|
|
93
|
+
console.error("Wrote the configuration to .env.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.error(`
|
|
97
|
+
Almost there — one last step wrangler cannot do:
|
|
98
|
+
1. Open ${DASHBOARD_TOKENS_URL}
|
|
99
|
+
2. Create an API token with "Object Read & Write" on "${bucket}"
|
|
100
|
+
3. Put the two values in .env as S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
|
|
101
|
+
|
|
102
|
+
Then take your first cliché:
|
|
103
|
+
bunx @voila.dev/cliche https://example.com shot.png --upload`);
|
|
104
|
+
}
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
export const SKILL_TARGET = ".claude/skills/pr-screenshots/SKILL.md";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Install the packaged PR-screenshots skill into the current repository.
|
|
7
|
+
* The skill ships with the package (`skill/SKILL.md`), so this works offline
|
|
8
|
+
* and always matches the installed cliche version.
|
|
9
|
+
*/
|
|
10
|
+
export async function installSkill(): Promise<string> {
|
|
11
|
+
const source = fileURLToPath(new URL("../skill/SKILL.md", import.meta.url));
|
|
12
|
+
await Bun.write(SKILL_TARGET, Bun.file(source));
|
|
13
|
+
return SKILL_TARGET;
|
|
14
|
+
}
|
package/src/upload.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
import { captionOf, objectKeyOf } from "./keys.ts";
|
|
3
|
+
|
|
4
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
5
|
+
".png": "image/png",
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".jpeg": "image/jpeg",
|
|
8
|
+
".webp": "image/webp",
|
|
9
|
+
".gif": "image/gif",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface UploadOptions {
|
|
13
|
+
readonly files: ReadonlyArray<string>;
|
|
14
|
+
/** Object key prefix, e.g. `pr-123`. Defaults to `cliche`. */
|
|
15
|
+
readonly prefix?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UploadedFile {
|
|
19
|
+
readonly file: string;
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly url: string;
|
|
22
|
+
/** A ready-to-paste `` line. */
|
|
23
|
+
readonly markdown: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The subset of Bun.S3Client the upload needs; injectable for tests. */
|
|
27
|
+
export interface ObjectWriter {
|
|
28
|
+
write(key: string, bytes: Uint8Array, options: { type: string }): Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function publicBaseUrl(): string {
|
|
32
|
+
const configured = process.env.CLICHE_PUBLIC_URL;
|
|
33
|
+
if (configured !== undefined) return configured.replace(/\/$/, "");
|
|
34
|
+
const bucket = process.env.S3_BUCKET ?? process.env.AWS_BUCKET;
|
|
35
|
+
if (bucket === undefined) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"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.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const endpoint = process.env.S3_ENDPOINT ?? process.env.AWS_ENDPOINT;
|
|
41
|
+
if (endpoint !== undefined) return `${endpoint.replace(/\/$/, "")}/${bucket}`;
|
|
42
|
+
const region = process.env.AWS_REGION ?? process.env.S3_REGION ?? "us-east-1";
|
|
43
|
+
return `https://${bucket}.s3.${region}.amazonaws.com`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Upload images to the S3-compatible bucket described by the standard
|
|
48
|
+
* environment variables Bun.S3Client already reads (S3_* / AWS_*), and
|
|
49
|
+
* return one markdown line per file. `CLICHE_PUBLIC_URL` overrides the
|
|
50
|
+
* public base URL (custom domains, R2 public buckets).
|
|
51
|
+
*/
|
|
52
|
+
export async function upload(options: UploadOptions, writer?: ObjectWriter): Promise<Array<UploadedFile>> {
|
|
53
|
+
const baseUrl = publicBaseUrl();
|
|
54
|
+
const client = writer ?? new Bun.S3Client();
|
|
55
|
+
const prefix = options.prefix ?? "cliche";
|
|
56
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
57
|
+
const uploaded: Array<UploadedFile> = [];
|
|
58
|
+
for (const file of options.files) {
|
|
59
|
+
const contentType = CONTENT_TYPES[extname(file).toLowerCase()];
|
|
60
|
+
if (contentType === undefined) {
|
|
61
|
+
console.error(`Skipping ${file}: unsupported extension`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const bytes = await Bun.file(file).bytes();
|
|
65
|
+
const key = objectKeyOf(prefix, file, bytes, date);
|
|
66
|
+
await client.write(key, bytes, { type: contentType });
|
|
67
|
+
console.error(`Uploaded ${file} -> ${key}`);
|
|
68
|
+
const url = `${baseUrl}/${key}`;
|
|
69
|
+
uploaded.push({ file, key, url, markdown: `` });
|
|
70
|
+
}
|
|
71
|
+
return uploaded;
|
|
72
|
+
}
|