portfolio-capture 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/dist/cli.js +431 -0
- package/dist/cli.js.map +1 -0
- package/package.json +24 -0
- package/template/.claude/hooks/detect-new-screen.mjs +47 -0
- package/template/.claude/settings.snippet.json +15 -0
- package/template/.claude/skills/add-to-portfolio/SKILL.md +50 -0
- package/template/.claude/skills/portfolio-sync/SKILL.md +24 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { access } from "fs/promises";
|
|
5
|
+
import { resolve as resolve3 } from "path";
|
|
6
|
+
|
|
7
|
+
// ../core/dist/save.js
|
|
8
|
+
async function saveCapture(client, blob, target, opts = {}) {
|
|
9
|
+
const filename = opts.filename ?? `snapshot-${stamp()}.png`;
|
|
10
|
+
const url = await client.upload(blob, filename);
|
|
11
|
+
if (target.kind === "new") {
|
|
12
|
+
const project = await client.createProject({
|
|
13
|
+
title: target.title?.trim() || "Untitled project",
|
|
14
|
+
description: target.description ?? "",
|
|
15
|
+
heroImageUrl: url,
|
|
16
|
+
sourceUrl: opts.sourceUrl
|
|
17
|
+
});
|
|
18
|
+
return { kind: "created", projectId: project.id, projectSlug: project.slug, url };
|
|
19
|
+
}
|
|
20
|
+
if (target.kind === "view") {
|
|
21
|
+
const tile = await client.createViewTile(target.viewId, {
|
|
22
|
+
title: target.title?.trim() || "",
|
|
23
|
+
description: target.description ?? "",
|
|
24
|
+
heroImageUrl: url
|
|
25
|
+
});
|
|
26
|
+
return { kind: "view-tile-created", viewProjectId: tile.id, viewSlug: tile.viewSlug, url };
|
|
27
|
+
}
|
|
28
|
+
await client.addImage(target.projectId, {
|
|
29
|
+
url,
|
|
30
|
+
surfaceId: target.surfaceId,
|
|
31
|
+
caption: target.caption
|
|
32
|
+
});
|
|
33
|
+
return { kind: "added", projectId: target.projectId, url };
|
|
34
|
+
}
|
|
35
|
+
function stamp() {
|
|
36
|
+
return String(Date.now());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ../core/dist/fetch-client.js
|
|
40
|
+
var FetchPortfolioClient = class {
|
|
41
|
+
base;
|
|
42
|
+
token;
|
|
43
|
+
constructor(opts) {
|
|
44
|
+
this.base = opts.baseUrl.replace(/\/$/, "");
|
|
45
|
+
this.token = opts.token;
|
|
46
|
+
}
|
|
47
|
+
headers(json = false) {
|
|
48
|
+
const h = { Authorization: `Bearer ${this.token}` };
|
|
49
|
+
if (json)
|
|
50
|
+
h["content-type"] = "application/json";
|
|
51
|
+
return h;
|
|
52
|
+
}
|
|
53
|
+
async get(path) {
|
|
54
|
+
const res = await fetch(`${this.base}${path}`, { headers: this.headers() });
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw await httpError(res);
|
|
57
|
+
return await res.json();
|
|
58
|
+
}
|
|
59
|
+
listProjects() {
|
|
60
|
+
return this.get("/api/projects");
|
|
61
|
+
}
|
|
62
|
+
listViews() {
|
|
63
|
+
return this.get("/api/views");
|
|
64
|
+
}
|
|
65
|
+
listSurfaces(projectId) {
|
|
66
|
+
return this.get(`/api/projects/${projectId}/surfaces`);
|
|
67
|
+
}
|
|
68
|
+
async createSurface(projectId, name) {
|
|
69
|
+
const trimmed = name.trim();
|
|
70
|
+
if (!trimmed)
|
|
71
|
+
throw new Error("surface name required");
|
|
72
|
+
const res = await fetch(`${this.base}/api/projects/${projectId}/surfaces`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: this.headers(true),
|
|
75
|
+
body: JSON.stringify({ name: trimmed })
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw await httpError(res);
|
|
79
|
+
return await res.json();
|
|
80
|
+
}
|
|
81
|
+
async upload(blob, filename) {
|
|
82
|
+
const fd = new FormData();
|
|
83
|
+
fd.append("file", new File([blob], filename, { type: "image/png" }));
|
|
84
|
+
const res = await fetch(`${this.base}/api/upload`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: this.headers(),
|
|
87
|
+
body: fd
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok)
|
|
90
|
+
throw await httpError(res, "upload failed");
|
|
91
|
+
const { url } = await res.json();
|
|
92
|
+
return url;
|
|
93
|
+
}
|
|
94
|
+
async createProject(input) {
|
|
95
|
+
const res = await fetch(`${this.base}/api/projects`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: this.headers(true),
|
|
98
|
+
body: JSON.stringify(input)
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok)
|
|
101
|
+
throw await httpError(res, "create project");
|
|
102
|
+
return await res.json();
|
|
103
|
+
}
|
|
104
|
+
async addImage(projectId, input) {
|
|
105
|
+
const res = await fetch(`${this.base}/api/projects/${projectId}/images`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: this.headers(true),
|
|
108
|
+
body: JSON.stringify(input)
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
throw await httpError(res, "add image");
|
|
112
|
+
}
|
|
113
|
+
async createViewTile(viewId, input) {
|
|
114
|
+
const res = await fetch(`${this.base}/api/views/${viewId}/projects`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: this.headers(true),
|
|
117
|
+
body: JSON.stringify(input)
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok)
|
|
120
|
+
throw await httpError(res, "create view tile");
|
|
121
|
+
const tile = await res.json();
|
|
122
|
+
return { id: tile.id, viewSlug: tile.view?.slug ?? "" };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
async function httpError(res, prefix = "request") {
|
|
126
|
+
const body = await res.text().catch(() => "");
|
|
127
|
+
return new Error(`${prefix}: HTTP ${res.status} ${body.slice(0, 160)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/config.ts
|
|
131
|
+
function loadConfig(flags) {
|
|
132
|
+
return {
|
|
133
|
+
portfolioUrl: str(flags.portfolioUrl) ?? process.env.PORTFOLIO_MD_URL ?? "http://localhost:3001",
|
|
134
|
+
token: str(flags.token) ?? process.env.PORTFOLIO_MD_TOKEN ?? "",
|
|
135
|
+
appUrl: str(flags.appUrl) ?? process.env.APP_DEV_URL ?? "http://localhost:3000"
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function str(v) {
|
|
139
|
+
return typeof v === "string" ? v : void 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/capture.ts
|
|
143
|
+
import { writeFile } from "fs/promises";
|
|
144
|
+
import { resolve } from "path";
|
|
145
|
+
async function captureRoute(opts) {
|
|
146
|
+
const url = joinUrl(opts.appUrl, opts.route);
|
|
147
|
+
const { chromium } = await import("playwright");
|
|
148
|
+
const browser = await chromium.launch();
|
|
149
|
+
try {
|
|
150
|
+
const context = await browser.newContext(
|
|
151
|
+
opts.storageState ? { storageState: opts.storageState } : {}
|
|
152
|
+
);
|
|
153
|
+
const page = await context.newPage();
|
|
154
|
+
const resp = await page.goto(url, { waitUntil: "networkidle", timeout: 3e4 }).catch(() => null);
|
|
155
|
+
if (!resp) return { status: "not-renderable", reason: "navigation failed/timed out", url };
|
|
156
|
+
if (resp.status() >= 400)
|
|
157
|
+
return { status: "not-renderable", reason: `HTTP ${resp.status()}`, url };
|
|
158
|
+
if (looksLikeLogin(page.url(), opts.route))
|
|
159
|
+
return { status: "not-renderable", reason: `redirected to ${page.url()} (auth wall?)`, url };
|
|
160
|
+
const text = (await page.evaluate(() => document.body?.innerText ?? "")).trim();
|
|
161
|
+
if (!opts.selector && text.length < 20)
|
|
162
|
+
return { status: "not-renderable", reason: "page rendered almost no content", url };
|
|
163
|
+
let buf;
|
|
164
|
+
if (opts.selector) {
|
|
165
|
+
const el = await page.$(opts.selector);
|
|
166
|
+
if (!el) return { status: "not-renderable", reason: `selector "${opts.selector}" not found`, url };
|
|
167
|
+
buf = await el.screenshot({ type: "png" });
|
|
168
|
+
} else {
|
|
169
|
+
buf = await page.screenshot({ fullPage: true, type: "png" });
|
|
170
|
+
}
|
|
171
|
+
const filename = `screen-${slug(opts.route)}.png`;
|
|
172
|
+
if (opts.dryRun) {
|
|
173
|
+
const pngPath = resolve(process.cwd(), filename);
|
|
174
|
+
await writeFile(pngPath, buf);
|
|
175
|
+
return { status: "dry-run", pngPath, planned: describeTarget(opts.target), url };
|
|
176
|
+
}
|
|
177
|
+
const client = new FetchPortfolioClient({ baseUrl: opts.portfolioUrl, token: opts.token });
|
|
178
|
+
const blob = new Blob([new Uint8Array(buf)], { type: "image/png" });
|
|
179
|
+
const result = await saveCapture(client, blob, opts.target, { sourceUrl: url, filename });
|
|
180
|
+
return { status: "captured", result, pngBytes: buf.length, url };
|
|
181
|
+
} finally {
|
|
182
|
+
await browser.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function joinUrl(base, route) {
|
|
186
|
+
return `${base.replace(/\/$/, "")}/${route.replace(/^\//, "")}`.replace(/\/$/, "") || base;
|
|
187
|
+
}
|
|
188
|
+
function looksLikeLogin(finalUrl, route) {
|
|
189
|
+
const path = (() => {
|
|
190
|
+
try {
|
|
191
|
+
return new URL(finalUrl).pathname;
|
|
192
|
+
} catch {
|
|
193
|
+
return finalUrl;
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
if (/\/(login|signin|sign-in|auth)\b/i.test(route)) return false;
|
|
197
|
+
return /\/(login|signin|sign-in|auth)\b/i.test(path);
|
|
198
|
+
}
|
|
199
|
+
function slug(route) {
|
|
200
|
+
return route.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "root";
|
|
201
|
+
}
|
|
202
|
+
function describeTarget(t) {
|
|
203
|
+
if (t.kind === "new") return `new project "${t.title ?? "Untitled"}"`;
|
|
204
|
+
if (t.kind === "view") return `view ${t.viewId} tile`;
|
|
205
|
+
return `project ${t.projectId}${t.surfaceId ? ` / surface ${t.surfaceId}` : ""}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/auth.ts
|
|
209
|
+
import { stdin, stdout } from "process";
|
|
210
|
+
async function saveLogin(opts) {
|
|
211
|
+
const { chromium } = await import("playwright");
|
|
212
|
+
const browser = await chromium.launch({ headless: false });
|
|
213
|
+
try {
|
|
214
|
+
const context = await browser.newContext();
|
|
215
|
+
const page = await context.newPage();
|
|
216
|
+
const url = `${opts.appUrl.replace(/\/$/, "")}/${(opts.route ?? "/").replace(/^\//, "")}`;
|
|
217
|
+
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {
|
|
218
|
+
});
|
|
219
|
+
stdout.write(
|
|
220
|
+
`
|
|
221
|
+
A browser opened at ${url}.
|
|
222
|
+
Log in there, then press Enter here to save the session\u2026`
|
|
223
|
+
);
|
|
224
|
+
await waitForEnter();
|
|
225
|
+
await context.storageState({ path: opts.statePath });
|
|
226
|
+
stdout.write(
|
|
227
|
+
`
|
|
228
|
+
\u2713 saved session to ${opts.statePath}
|
|
229
|
+
Keep this file out of git \u2014 it holds your login cookies.
|
|
230
|
+
`
|
|
231
|
+
);
|
|
232
|
+
} finally {
|
|
233
|
+
await browser.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function waitForEnter() {
|
|
237
|
+
return new Promise((resolve4) => {
|
|
238
|
+
stdin.resume();
|
|
239
|
+
stdin.once("data", () => {
|
|
240
|
+
stdin.pause();
|
|
241
|
+
resolve4();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/install.ts
|
|
247
|
+
import { cp, mkdir, readFile, writeFile as writeFile2 } from "fs/promises";
|
|
248
|
+
import { fileURLToPath } from "url";
|
|
249
|
+
import { resolve as resolve2 } from "path";
|
|
250
|
+
var HOOK_CMD = "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/detect-new-screen.mjs";
|
|
251
|
+
async function runInit(cwd) {
|
|
252
|
+
const log = [];
|
|
253
|
+
const templateClaude = fileURLToPath(new URL("../template/.claude", import.meta.url));
|
|
254
|
+
const dest = resolve2(cwd, ".claude");
|
|
255
|
+
await mkdir(dest, { recursive: true });
|
|
256
|
+
await cp(resolve2(templateClaude, "skills"), resolve2(dest, "skills"), { recursive: true });
|
|
257
|
+
await cp(resolve2(templateClaude, "hooks"), resolve2(dest, "hooks"), { recursive: true });
|
|
258
|
+
log.push("copied .claude/skills/{add-to-portfolio,portfolio-sync} + .claude/hooks/detect-new-screen.mjs");
|
|
259
|
+
const settingsPath = resolve2(dest, "settings.json");
|
|
260
|
+
let settings = {};
|
|
261
|
+
try {
|
|
262
|
+
settings = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
settings.hooks ??= {};
|
|
266
|
+
settings.hooks.PostToolUse ??= [];
|
|
267
|
+
const already = JSON.stringify(settings.hooks.PostToolUse).includes("detect-new-screen.mjs");
|
|
268
|
+
if (already) {
|
|
269
|
+
log.push("PostToolUse hook already present \u2014 settings.json left as-is");
|
|
270
|
+
} else {
|
|
271
|
+
settings.hooks.PostToolUse.push({
|
|
272
|
+
matcher: "Write|Edit",
|
|
273
|
+
hooks: [{ type: "command", command: HOOK_CMD }]
|
|
274
|
+
});
|
|
275
|
+
log.push("registered the PostToolUse hook in .claude/settings.json");
|
|
276
|
+
}
|
|
277
|
+
await writeFile2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
278
|
+
const gitignore = resolve2(cwd, ".gitignore");
|
|
279
|
+
let contents = "";
|
|
280
|
+
try {
|
|
281
|
+
contents = await readFile(gitignore, "utf8");
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
if (!contents.split(/\r?\n/).includes(".portfolio-auth.json")) {
|
|
285
|
+
await writeFile2(gitignore, (contents && !contents.endsWith("\n") ? contents + "\n" : contents) + ".portfolio-auth.json\n");
|
|
286
|
+
log.push("added .portfolio-auth.json to .gitignore");
|
|
287
|
+
}
|
|
288
|
+
return log;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/cli.ts
|
|
292
|
+
var DEFAULT_STATE = ".portfolio-auth.json";
|
|
293
|
+
async function main() {
|
|
294
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
295
|
+
const { flags } = parseArgs(rest);
|
|
296
|
+
switch (command) {
|
|
297
|
+
case "capture":
|
|
298
|
+
return capture(flags);
|
|
299
|
+
case "list-projects":
|
|
300
|
+
return list(flags, "projects");
|
|
301
|
+
case "list-views":
|
|
302
|
+
return list(flags, "views");
|
|
303
|
+
case "login":
|
|
304
|
+
return login(flags);
|
|
305
|
+
case "init":
|
|
306
|
+
for (const line of await runInit(process.cwd())) console.log("\u2713", line);
|
|
307
|
+
console.log(
|
|
308
|
+
"\nSet PORTFOLIO_MD_URL, PORTFOLIO_MD_TOKEN, APP_DEV_URL (or pass --portfolio-url/--token/--app-url),\nand add `portfolio-capture` as a devDependency. The hook fires on Claude's file writes;\nrun /portfolio-sync to sweep hand-coded screens."
|
|
309
|
+
);
|
|
310
|
+
return 0;
|
|
311
|
+
default:
|
|
312
|
+
console.log(USAGE);
|
|
313
|
+
return command ? 1 : 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async function capture(flags) {
|
|
317
|
+
const cfg = loadConfig(flags);
|
|
318
|
+
const route = str2(flags.route);
|
|
319
|
+
if (!route) return fail("--route is required (e.g. --route /dashboard)");
|
|
320
|
+
if (!flags.dryRun && !cfg.token) return fail("no token (set PORTFOLIO_MD_TOKEN or --token), or use --dry-run");
|
|
321
|
+
const storageState = str2(flags.storageState) ?? await defaultStateIfExists();
|
|
322
|
+
if (storageState && !str2(flags.storageState)) console.log(`(using saved session ${storageState})`);
|
|
323
|
+
const outcome = await captureRoute({
|
|
324
|
+
appUrl: cfg.appUrl,
|
|
325
|
+
route,
|
|
326
|
+
portfolioUrl: cfg.portfolioUrl,
|
|
327
|
+
token: cfg.token,
|
|
328
|
+
target: parseTarget(flags),
|
|
329
|
+
selector: str2(flags.selector),
|
|
330
|
+
storageState,
|
|
331
|
+
dryRun: !!flags.dryRun
|
|
332
|
+
});
|
|
333
|
+
switch (outcome.status) {
|
|
334
|
+
case "captured":
|
|
335
|
+
console.log(`\u2713 captured ${outcome.url} (${outcome.pngBytes}b) \u2192 ${linkOf(outcome.result, cfg.portfolioUrl)}`);
|
|
336
|
+
return 0;
|
|
337
|
+
case "dry-run":
|
|
338
|
+
console.log(`\u2713 dry-run: wrote ${outcome.pngPath}; would create ${outcome.planned}`);
|
|
339
|
+
return 0;
|
|
340
|
+
case "not-renderable":
|
|
341
|
+
console.error(`\u26A0 ${outcome.url} not renderable headlessly: ${outcome.reason}`);
|
|
342
|
+
console.error(`\u2192 open ${outcome.url} and capture it with the snapshot-tray/extension instead.`);
|
|
343
|
+
return 3;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function list(flags, what) {
|
|
347
|
+
const cfg = loadConfig(flags);
|
|
348
|
+
if (!cfg.token) return fail("no token (set PORTFOLIO_MD_TOKEN or --token)");
|
|
349
|
+
const client = new FetchPortfolioClient({ baseUrl: cfg.portfolioUrl, token: cfg.token });
|
|
350
|
+
if (what === "projects") {
|
|
351
|
+
for (const p of await client.listProjects()) console.log(`proj:${p.id} ${p.title || p.slug}`);
|
|
352
|
+
} else {
|
|
353
|
+
for (const v of await client.listViews()) console.log(`view:${v.id} ${v.name}`);
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
async function login(flags) {
|
|
358
|
+
const cfg = loadConfig(flags);
|
|
359
|
+
const statePath = str2(flags.state) ?? str2(flags.storageState) ?? resolve3(process.cwd(), DEFAULT_STATE);
|
|
360
|
+
await saveLogin({ appUrl: cfg.appUrl, route: str2(flags.route), statePath });
|
|
361
|
+
return 0;
|
|
362
|
+
}
|
|
363
|
+
async function defaultStateIfExists() {
|
|
364
|
+
const p = resolve3(process.cwd(), DEFAULT_STATE);
|
|
365
|
+
try {
|
|
366
|
+
await access(p);
|
|
367
|
+
return p;
|
|
368
|
+
} catch {
|
|
369
|
+
return void 0;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function parseTarget(flags) {
|
|
373
|
+
const t = str2(flags.target) ?? "new";
|
|
374
|
+
const title = str2(flags.title);
|
|
375
|
+
const description = str2(flags.description);
|
|
376
|
+
if (t === "new") return { kind: "new", title, description };
|
|
377
|
+
if (t.startsWith("view:")) return { kind: "view", viewId: t.slice(5), title, description };
|
|
378
|
+
const projectId = t.startsWith("proj:") ? t.slice(5) : t;
|
|
379
|
+
return { kind: "existing", projectId, surfaceId: str2(flags.surface), caption: str2(flags.caption) };
|
|
380
|
+
}
|
|
381
|
+
function linkOf(r, base) {
|
|
382
|
+
const b = base.replace(/\/$/, "");
|
|
383
|
+
if (r.kind === "created" && r.projectSlug) return `${b}/projects/${r.projectSlug}`;
|
|
384
|
+
if (r.kind === "view-tile-created" && r.viewSlug) return `${b}/v/${r.viewSlug}`;
|
|
385
|
+
return b;
|
|
386
|
+
}
|
|
387
|
+
function parseArgs(argv) {
|
|
388
|
+
const flags = {};
|
|
389
|
+
const positional = [];
|
|
390
|
+
for (let i = 0; i < argv.length; i++) {
|
|
391
|
+
const a = argv[i];
|
|
392
|
+
if (a.startsWith("--")) {
|
|
393
|
+
const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
394
|
+
const next = argv[i + 1];
|
|
395
|
+
if (next === void 0 || next.startsWith("--")) flags[key] = true;
|
|
396
|
+
else {
|
|
397
|
+
flags[key] = next;
|
|
398
|
+
i++;
|
|
399
|
+
}
|
|
400
|
+
} else positional.push(a);
|
|
401
|
+
}
|
|
402
|
+
return { flags, positional };
|
|
403
|
+
}
|
|
404
|
+
function str2(v) {
|
|
405
|
+
return typeof v === "string" ? v : void 0;
|
|
406
|
+
}
|
|
407
|
+
function fail(msg) {
|
|
408
|
+
console.error(`error: ${msg}`);
|
|
409
|
+
return 1;
|
|
410
|
+
}
|
|
411
|
+
var USAGE = `portfolio-capture <command>
|
|
412
|
+
|
|
413
|
+
capture --route /path [--app-url URL] [--portfolio-url URL] [--token T]
|
|
414
|
+
[--target new|proj:<id>|view:<id>] [--title T] [--description D]
|
|
415
|
+
[--surface <id>] [--caption C] [--selector CSS] [--storage-state file]
|
|
416
|
+
[--dry-run]
|
|
417
|
+
list-projects [--portfolio-url URL] [--token T]
|
|
418
|
+
list-views [--portfolio-url URL] [--token T]
|
|
419
|
+
login [--app-url URL] [--route /path] [--state file]
|
|
420
|
+
open a headed browser, log in, save the session for authed captures
|
|
421
|
+
init scaffold .claude/ skills + hook into the current repo
|
|
422
|
+
|
|
423
|
+
Capture auto-uses ./.portfolio-auth.json (from \`login\`) when present.
|
|
424
|
+
Env: PORTFOLIO_MD_URL, PORTFOLIO_MD_TOKEN, APP_DEV_URL`;
|
|
425
|
+
main().then((code) => {
|
|
426
|
+
process.exitCode = code;
|
|
427
|
+
}).catch((err) => {
|
|
428
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
});
|
|
431
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts","../../core/src/save.ts","../../core/src/fetch-client.ts","../src/config.ts","../src/capture.ts","../src/auth.ts","../src/install.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { access } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport { FetchPortfolioClient, type SaveTarget } from \"@snapshot/core\";\nimport { loadConfig } from \"./config.js\";\nimport { captureRoute } from \"./capture.js\";\nimport { saveLogin } from \"./auth.js\";\nimport { runInit } from \"./install.js\";\n\n/** Default Playwright session file picked up by `capture` when present. */\nconst DEFAULT_STATE = \".portfolio-auth.json\";\n\nasync function main(): Promise<number> {\n const [command, ...rest] = process.argv.slice(2);\n const { flags } = parseArgs(rest);\n\n switch (command) {\n case \"capture\":\n return capture(flags);\n case \"list-projects\":\n return list(flags, \"projects\");\n case \"list-views\":\n return list(flags, \"views\");\n case \"login\":\n return login(flags);\n case \"init\":\n for (const line of await runInit(process.cwd())) console.log(\"✓\", line);\n console.log(\n \"\\nSet PORTFOLIO_MD_URL, PORTFOLIO_MD_TOKEN, APP_DEV_URL (or pass --portfolio-url/--token/--app-url),\\n\" +\n \"and add `portfolio-capture` as a devDependency. The hook fires on Claude's file writes;\\n\" +\n \"run /portfolio-sync to sweep hand-coded screens.\"\n );\n return 0;\n default:\n console.log(USAGE);\n return command ? 1 : 0;\n }\n}\n\nasync function capture(flags: Flags): Promise<number> {\n const cfg = loadConfig(flags);\n const route = str(flags.route);\n if (!route) return fail(\"--route is required (e.g. --route /dashboard)\");\n if (!flags.dryRun && !cfg.token) return fail(\"no token (set PORTFOLIO_MD_TOKEN or --token), or use --dry-run\");\n\n // Use an explicit --storage-state, else auto-pick the saved login if present.\n const storageState = str(flags.storageState) ?? (await defaultStateIfExists());\n if (storageState && !str(flags.storageState)) console.log(`(using saved session ${storageState})`);\n\n const outcome = await captureRoute({\n appUrl: cfg.appUrl,\n route,\n portfolioUrl: cfg.portfolioUrl,\n token: cfg.token,\n target: parseTarget(flags),\n selector: str(flags.selector),\n storageState,\n dryRun: !!flags.dryRun,\n });\n\n switch (outcome.status) {\n case \"captured\":\n console.log(`✓ captured ${outcome.url} (${outcome.pngBytes}b) → ${linkOf(outcome.result, cfg.portfolioUrl)}`);\n return 0;\n case \"dry-run\":\n console.log(`✓ dry-run: wrote ${outcome.pngPath}; would create ${outcome.planned}`);\n return 0;\n case \"not-renderable\":\n console.error(`⚠ ${outcome.url} not renderable headlessly: ${outcome.reason}`);\n console.error(`→ open ${outcome.url} and capture it with the snapshot-tray/extension instead.`);\n return 3;\n }\n}\n\nasync function list(flags: Flags, what: \"projects\" | \"views\"): Promise<number> {\n const cfg = loadConfig(flags);\n if (!cfg.token) return fail(\"no token (set PORTFOLIO_MD_TOKEN or --token)\");\n const client = new FetchPortfolioClient({ baseUrl: cfg.portfolioUrl, token: cfg.token });\n if (what === \"projects\") {\n for (const p of await client.listProjects()) console.log(`proj:${p.id}\\t${p.title || p.slug}`);\n } else {\n for (const v of await client.listViews()) console.log(`view:${v.id}\\t${v.name}`);\n }\n return 0;\n}\n\nasync function login(flags: Flags): Promise<number> {\n const cfg = loadConfig(flags);\n const statePath = str(flags.state) ?? str(flags.storageState) ?? resolve(process.cwd(), DEFAULT_STATE);\n await saveLogin({ appUrl: cfg.appUrl, route: str(flags.route), statePath });\n return 0;\n}\n\nasync function defaultStateIfExists(): Promise<string | undefined> {\n const p = resolve(process.cwd(), DEFAULT_STATE);\n try {\n await access(p);\n return p;\n } catch {\n return undefined;\n }\n}\n\nfunction parseTarget(flags: Flags): SaveTarget {\n const t = str(flags.target) ?? \"new\";\n const title = str(flags.title);\n const description = str(flags.description);\n if (t === \"new\") return { kind: \"new\", title, description };\n if (t.startsWith(\"view:\")) return { kind: \"view\", viewId: t.slice(5), title, description };\n const projectId = t.startsWith(\"proj:\") ? t.slice(5) : t;\n return { kind: \"existing\", projectId, surfaceId: str(flags.surface), caption: str(flags.caption) };\n}\n\nfunction linkOf(r: { kind: string; projectSlug?: string; viewSlug?: string }, base: string): string {\n const b = base.replace(/\\/$/, \"\");\n if (r.kind === \"created\" && r.projectSlug) return `${b}/projects/${r.projectSlug}`;\n if (r.kind === \"view-tile-created\" && r.viewSlug) return `${b}/v/${r.viewSlug}`;\n return b;\n}\n\n// ── tiny arg parser ──\ntype Flags = Record<string, string | boolean>;\nfunction parseArgs(argv: string[]): { flags: Flags; positional: string[] } {\n const flags: Flags = {};\n const positional: string[] = [];\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i];\n if (a.startsWith(\"--\")) {\n const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());\n const next = argv[i + 1];\n if (next === undefined || next.startsWith(\"--\")) flags[key] = true;\n else { flags[key] = next; i++; }\n } else positional.push(a);\n }\n return { flags, positional };\n}\n\nfunction str(v: string | boolean | undefined): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\nfunction fail(msg: string): number {\n console.error(`error: ${msg}`);\n return 1;\n}\n\nconst USAGE = `portfolio-capture <command>\n\n capture --route /path [--app-url URL] [--portfolio-url URL] [--token T]\n [--target new|proj:<id>|view:<id>] [--title T] [--description D]\n [--surface <id>] [--caption C] [--selector CSS] [--storage-state file]\n [--dry-run]\n list-projects [--portfolio-url URL] [--token T]\n list-views [--portfolio-url URL] [--token T]\n login [--app-url URL] [--route /path] [--state file]\n open a headed browser, log in, save the session for authed captures\n init scaffold .claude/ skills + hook into the current repo\n\nCapture auto-uses ./.portfolio-auth.json (from \\`login\\`) when present.\nEnv: PORTFOLIO_MD_URL, PORTFOLIO_MD_TOKEN, APP_DEV_URL`;\n\nmain()\n .then((code) => { process.exitCode = code; })\n .catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exitCode = 1; });\n","import type { PortfolioClient } from \"./portfolio-client.js\";\nimport type { SaveTarget, SaveResult } from \"./types.js\";\n\n/**\n * Upload a captured PNG and route it to its target, mirroring the extension's\n * three runSave branches (new project / view tile / append to existing). Shared\n * so the tray's batch save and any single save go through identical logic.\n */\nexport async function saveCapture(\n client: PortfolioClient,\n blob: Blob,\n target: SaveTarget,\n opts: { sourceUrl?: string; filename?: string } = {}\n): Promise<SaveResult> {\n const filename = opts.filename ?? `snapshot-${stamp()}.png`;\n const url = await client.upload(blob, filename);\n\n if (target.kind === \"new\") {\n const project = await client.createProject({\n title: target.title?.trim() || \"Untitled project\",\n description: target.description ?? \"\",\n heroImageUrl: url,\n sourceUrl: opts.sourceUrl,\n });\n return { kind: \"created\", projectId: project.id, projectSlug: project.slug, url };\n }\n\n if (target.kind === \"view\") {\n const tile = await client.createViewTile(target.viewId, {\n title: target.title?.trim() || \"\",\n description: target.description ?? \"\",\n heroImageUrl: url,\n });\n return { kind: \"view-tile-created\", viewProjectId: tile.id, viewSlug: tile.viewSlug, url };\n }\n\n await client.addImage(target.projectId, {\n url,\n surfaceId: target.surfaceId,\n caption: target.caption,\n });\n return { kind: \"added\", projectId: target.projectId, url };\n}\n\n/** Filename timestamp. Kept out of saveCapture so callers can override. */\nfunction stamp(): string {\n return String(Date.now());\n}\n","import type { PortfolioClient } from \"./portfolio-client.js\";\nimport type { ProjectStub, SurfaceStub, ViewStub } from \"./types.js\";\n\n/**\n * A PortfolioClient that talks to portfolio.md directly with fetch + a bearer\n * token. Environment-agnostic: runs in the browser (the tray) and in Node\n * 18.12+/20+ (the portfolio-capture CLI) — `fetch`/`FormData`/`File`/`Blob` are\n * globals in both. In the browser, cross-origin calls require CORS on\n * portfolio.md for the dev origin + the Authorization header.\n */\nexport class FetchPortfolioClient implements PortfolioClient {\n private readonly base: string;\n private readonly token: string;\n\n constructor(opts: { baseUrl: string; token: string }) {\n this.base = opts.baseUrl.replace(/\\/$/, \"\");\n this.token = opts.token;\n }\n\n private headers(json = false): HeadersInit {\n const h: Record<string, string> = { Authorization: `Bearer ${this.token}` };\n if (json) h[\"content-type\"] = \"application/json\";\n return h;\n }\n\n private async get<T>(path: string): Promise<T> {\n const res = await fetch(`${this.base}${path}`, { headers: this.headers() });\n if (!res.ok) throw await httpError(res);\n return (await res.json()) as T;\n }\n\n listProjects(): Promise<ProjectStub[]> {\n return this.get<ProjectStub[]>(\"/api/projects\");\n }\n\n listViews(): Promise<ViewStub[]> {\n return this.get<ViewStub[]>(\"/api/views\");\n }\n\n listSurfaces(projectId: string): Promise<SurfaceStub[]> {\n return this.get<SurfaceStub[]>(`/api/projects/${projectId}/surfaces`);\n }\n\n async createSurface(projectId: string, name: string): Promise<SurfaceStub> {\n const trimmed = name.trim();\n if (!trimmed) throw new Error(\"surface name required\");\n const res = await fetch(`${this.base}/api/projects/${projectId}/surfaces`, {\n method: \"POST\",\n headers: this.headers(true),\n body: JSON.stringify({ name: trimmed }),\n });\n if (!res.ok) throw await httpError(res);\n return (await res.json()) as SurfaceStub;\n }\n\n async upload(blob: Blob, filename: string): Promise<string> {\n const fd = new FormData();\n fd.append(\"file\", new File([blob], filename, { type: \"image/png\" }));\n const res = await fetch(`${this.base}/api/upload`, {\n method: \"POST\",\n headers: this.headers(),\n body: fd,\n });\n if (!res.ok) throw await httpError(res, \"upload failed\");\n const { url } = (await res.json()) as { url: string };\n return url;\n }\n\n async createProject(input: {\n title: string;\n description: string;\n heroImageUrl: string;\n sourceUrl?: string;\n }): Promise<{ id: string; slug: string }> {\n const res = await fetch(`${this.base}/api/projects`, {\n method: \"POST\",\n headers: this.headers(true),\n body: JSON.stringify(input),\n });\n if (!res.ok) throw await httpError(res, \"create project\");\n return (await res.json()) as { id: string; slug: string };\n }\n\n async addImage(\n projectId: string,\n input: { url: string; surfaceId?: string; caption?: string }\n ): Promise<void> {\n const res = await fetch(`${this.base}/api/projects/${projectId}/images`, {\n method: \"POST\",\n headers: this.headers(true),\n body: JSON.stringify(input),\n });\n if (!res.ok) throw await httpError(res, \"add image\");\n }\n\n async createViewTile(\n viewId: string,\n input: { title: string; description: string; heroImageUrl: string }\n ): Promise<{ id: string; viewSlug: string }> {\n const res = await fetch(`${this.base}/api/views/${viewId}/projects`, {\n method: \"POST\",\n headers: this.headers(true),\n body: JSON.stringify(input),\n });\n if (!res.ok) throw await httpError(res, \"create view tile\");\n const tile = (await res.json()) as { id: string; view?: { slug?: string } };\n return { id: tile.id, viewSlug: tile.view?.slug ?? \"\" };\n }\n}\n\nasync function httpError(res: Response, prefix = \"request\"): Promise<Error> {\n const body = await res.text().catch(() => \"\");\n return new Error(`${prefix}: HTTP ${res.status} ${body.slice(0, 160)}`);\n}\n","/** Resolved runtime config — flags override env, env overrides defaults. */\nexport type Config = {\n /** portfolio.md base URL (where uploads/projects go). */\n portfolioUrl: string;\n /** Bearer token for portfolio.md. */\n token: string;\n /** The app's dev server (where screens are rendered for screenshots). */\n appUrl: string;\n};\n\nexport function loadConfig(flags: Record<string, string | boolean>): Config {\n return {\n portfolioUrl:\n str(flags.portfolioUrl) ?? process.env.PORTFOLIO_MD_URL ?? \"http://localhost:3001\",\n token: str(flags.token) ?? process.env.PORTFOLIO_MD_TOKEN ?? \"\",\n appUrl: str(flags.appUrl) ?? process.env.APP_DEV_URL ?? \"http://localhost:3000\",\n };\n}\n\nfunction str(v: string | boolean | undefined): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n","import { writeFile } from \"node:fs/promises\";\nimport { resolve } from \"node:path\";\nimport {\n saveCapture,\n FetchPortfolioClient,\n type SaveTarget,\n type SaveResult,\n} from \"@snapshot/core\";\n\nexport type CaptureOpts = {\n appUrl: string;\n route: string;\n portfolioUrl: string;\n token: string;\n target: SaveTarget;\n selector?: string;\n storageState?: string;\n dryRun?: boolean;\n};\n\nexport type CaptureOutcome =\n | { status: \"captured\"; result: SaveResult; pngBytes: number; url: string }\n | { status: \"dry-run\"; pngPath: string; planned: string; url: string }\n | { status: \"not-renderable\"; reason: string; url: string };\n\n/**\n * Headless-capture a route on the app's dev server and route it to portfolio.md.\n * Returns \"not-renderable\" (the skill's signal to nudge a manual capture) when\n * the route redirects to a login wall, errors, or paints almost nothing.\n */\nexport async function captureRoute(opts: CaptureOpts): Promise<CaptureOutcome> {\n const url = joinUrl(opts.appUrl, opts.route);\n // Lazy import so `list`/`init` don't pay Playwright's load cost.\n const { chromium } = await import(\"playwright\");\n const browser = await chromium.launch();\n try {\n const context = await browser.newContext(\n opts.storageState ? { storageState: opts.storageState } : {}\n );\n const page = await context.newPage();\n const resp = await page\n .goto(url, { waitUntil: \"networkidle\", timeout: 30000 })\n .catch(() => null);\n\n if (!resp) return { status: \"not-renderable\", reason: \"navigation failed/timed out\", url };\n if (resp.status() >= 400)\n return { status: \"not-renderable\", reason: `HTTP ${resp.status()}`, url };\n if (looksLikeLogin(page.url(), opts.route))\n return { status: \"not-renderable\", reason: `redirected to ${page.url()} (auth wall?)`, url };\n\n const text = (await page.evaluate(() => document.body?.innerText ?? \"\")).trim();\n if (!opts.selector && text.length < 20)\n return { status: \"not-renderable\", reason: \"page rendered almost no content\", url };\n\n let buf: Buffer;\n if (opts.selector) {\n const el = await page.$(opts.selector);\n if (!el) return { status: \"not-renderable\", reason: `selector \"${opts.selector}\" not found`, url };\n buf = await el.screenshot({ type: \"png\" });\n } else {\n buf = await page.screenshot({ fullPage: true, type: \"png\" });\n }\n\n const filename = `screen-${slug(opts.route)}.png`;\n if (opts.dryRun) {\n const pngPath = resolve(process.cwd(), filename);\n await writeFile(pngPath, buf);\n return { status: \"dry-run\", pngPath, planned: describeTarget(opts.target), url };\n }\n\n const client = new FetchPortfolioClient({ baseUrl: opts.portfolioUrl, token: opts.token });\n // Copy into a fresh ArrayBuffer-backed view so the Blob type is satisfied.\n const blob = new Blob([new Uint8Array(buf)], { type: \"image/png\" });\n const result = await saveCapture(client, blob, opts.target, { sourceUrl: url, filename });\n return { status: \"captured\", result, pngBytes: buf.length, url };\n } finally {\n await browser.close();\n }\n}\n\nfunction joinUrl(base: string, route: string): string {\n return `${base.replace(/\\/$/, \"\")}/${route.replace(/^\\//, \"\")}`.replace(/\\/$/, \"\") || base;\n}\n\nfunction looksLikeLogin(finalUrl: string, route: string): boolean {\n const path = (() => {\n try { return new URL(finalUrl).pathname; } catch { return finalUrl; }\n })();\n if (/\\/(login|signin|sign-in|auth)\\b/i.test(route)) return false; // we asked for it\n return /\\/(login|signin|sign-in|auth)\\b/i.test(path);\n}\n\nfunction slug(route: string): string {\n return route.replace(/[^a-z0-9]+/gi, \"-\").replace(/^-+|-+$/g, \"\").toLowerCase() || \"root\";\n}\n\nfunction describeTarget(t: SaveTarget): string {\n if (t.kind === \"new\") return `new project \"${t.title ?? \"Untitled\"}\"`;\n if (t.kind === \"view\") return `view ${t.viewId} tile`;\n return `project ${t.projectId}${t.surfaceId ? ` / surface ${t.surfaceId}` : \"\"}`;\n}\n","import { stdin, stdout } from \"node:process\";\n\n/**\n * Open a headed browser at the app, let the user log in manually, then persist\n * the session (cookies + localStorage) to a Playwright storageState file. That\n * file can then be passed to `capture --storage-state` so authed routes render.\n */\nexport async function saveLogin(opts: {\n appUrl: string;\n route?: string;\n statePath: string;\n}): Promise<void> {\n const { chromium } = await import(\"playwright\");\n const browser = await chromium.launch({ headless: false });\n try {\n const context = await browser.newContext();\n const page = await context.newPage();\n const url = `${opts.appUrl.replace(/\\/$/, \"\")}/${(opts.route ?? \"/\").replace(/^\\//, \"\")}`;\n await page.goto(url, { waitUntil: \"domcontentloaded\" }).catch(() => {});\n stdout.write(\n `\\nA browser opened at ${url}.\\n` +\n `Log in there, then press Enter here to save the session…`\n );\n await waitForEnter();\n await context.storageState({ path: opts.statePath });\n stdout.write(\n `\\n✓ saved session to ${opts.statePath}\\n` +\n ` Keep this file out of git — it holds your login cookies.\\n`\n );\n } finally {\n await browser.close();\n }\n}\n\nfunction waitForEnter(): Promise<void> {\n return new Promise((resolve) => {\n stdin.resume();\n stdin.once(\"data\", () => {\n stdin.pause();\n resolve();\n });\n });\n}\n","import { cp, mkdir, readFile, writeFile } from \"node:fs/promises\";\nimport { fileURLToPath } from \"node:url\";\nimport { resolve } from \"node:path\";\n\nconst HOOK_CMD = \"node ${CLAUDE_PROJECT_DIR}/.claude/hooks/detect-new-screen.mjs\";\n\n/**\n * Scaffold the skill/hook into a target app repo's `.claude/`:\n * - copy the add-to-portfolio + portfolio-sync skills and the detect hook\n * - merge the PostToolUse hook into .claude/settings.json without clobbering\n * Returns a log of what it did.\n */\nexport async function runInit(cwd: string): Promise<string[]> {\n const log: string[] = [];\n const templateClaude = fileURLToPath(new URL(\"../template/.claude\", import.meta.url));\n const dest = resolve(cwd, \".claude\");\n await mkdir(dest, { recursive: true });\n\n await cp(resolve(templateClaude, \"skills\"), resolve(dest, \"skills\"), { recursive: true });\n await cp(resolve(templateClaude, \"hooks\"), resolve(dest, \"hooks\"), { recursive: true });\n log.push(\"copied .claude/skills/{add-to-portfolio,portfolio-sync} + .claude/hooks/detect-new-screen.mjs\");\n\n const settingsPath = resolve(dest, \"settings.json\");\n let settings: SettingsShape = {};\n try {\n settings = JSON.parse(await readFile(settingsPath, \"utf8\")) as SettingsShape;\n } catch {\n /* no existing settings — start fresh */\n }\n settings.hooks ??= {};\n settings.hooks.PostToolUse ??= [];\n const already = JSON.stringify(settings.hooks.PostToolUse).includes(\"detect-new-screen.mjs\");\n if (already) {\n log.push(\"PostToolUse hook already present — settings.json left as-is\");\n } else {\n settings.hooks.PostToolUse.push({\n matcher: \"Write|Edit\",\n hooks: [{ type: \"command\", command: HOOK_CMD }],\n });\n log.push(\"registered the PostToolUse hook in .claude/settings.json\");\n }\n await writeFile(settingsPath, JSON.stringify(settings, null, 2) + \"\\n\");\n\n // The saved-login session file holds cookies — keep it out of git.\n const gitignore = resolve(cwd, \".gitignore\");\n let contents = \"\";\n try {\n contents = await readFile(gitignore, \"utf8\");\n } catch {\n /* no .gitignore yet */\n }\n if (!contents.split(/\\r?\\n/).includes(\".portfolio-auth.json\")) {\n await writeFile(gitignore, (contents && !contents.endsWith(\"\\n\") ? contents + \"\\n\" : contents) + \".portfolio-auth.json\\n\");\n log.push(\"added .portfolio-auth.json to .gitignore\");\n }\n return log;\n}\n\ntype SettingsShape = {\n hooks?: {\n PostToolUse?: Array<{ matcher: string; hooks: Array<{ type: string; command: string }> }>;\n [k: string]: unknown;\n };\n [k: string]: unknown;\n};\n"],"mappings":"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","names":["resolve","resolve","writeFile","resolve","str","resolve"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "portfolio-capture",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + Claude Code skill/hook that detects new screens and captures them into portfolio.md.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "portfolio-capture": "./dist/cli.js" },
|
|
8
|
+
"main": "./dist/cli.js",
|
|
9
|
+
"files": ["dist", "template"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
13
|
+
"prepublishOnly": "pnpm run build"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"playwright": "^1.48.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@snapshot/core": "workspace:*",
|
|
20
|
+
"@types/node": "^22.0.0",
|
|
21
|
+
"tsup": "^8.3.0",
|
|
22
|
+
"typescript": "^5.6.3"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook. When Claude writes a file that looks like a new user-facing
|
|
3
|
+
// screen/page, inject a nudge suggesting /add-to-portfolio. The hook only fires
|
|
4
|
+
// for Claude's own Write/Edit — run /portfolio-sync to catch hand-coded screens.
|
|
5
|
+
import { stdin } from "node:process";
|
|
6
|
+
|
|
7
|
+
let raw = "";
|
|
8
|
+
for await (const chunk of stdin) raw += chunk;
|
|
9
|
+
|
|
10
|
+
let data;
|
|
11
|
+
try {
|
|
12
|
+
data = JSON.parse(raw || "{}");
|
|
13
|
+
} catch {
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const file = data?.tool_input?.file_path ?? "";
|
|
18
|
+
|
|
19
|
+
const SCREEN = [
|
|
20
|
+
/app\/.*\/(page|layout)\.[jt]sx?$/, // Next.js app router
|
|
21
|
+
/(^|\/)pages\/.*\.[jt]sx?$/, // Next.js pages router
|
|
22
|
+
/(^|\/)src\/screens\/.*\.[jt]sx?$/, // React Native / common screens dir
|
|
23
|
+
/(^|\/)app\/.*\.[jt]sx?$/, // Expo Router
|
|
24
|
+
];
|
|
25
|
+
const EXCLUDE = [
|
|
26
|
+
/\/components?\//i,
|
|
27
|
+
/\.(test|spec|stories)\.[jt]sx?$/,
|
|
28
|
+
/\/_[^/]*\.[jt]sx?$/, // _app, _document, private files
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const isScreen =
|
|
32
|
+
file && SCREEN.some((re) => re.test(file)) && !EXCLUDE.some((re) => re.test(file));
|
|
33
|
+
|
|
34
|
+
if (isScreen) {
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
hookSpecificOutput: {
|
|
38
|
+
hookEventName: "PostToolUse",
|
|
39
|
+
additionalContext:
|
|
40
|
+
`A new screen file was created: ${file}. If this is a user-facing ` +
|
|
41
|
+
`screen, offer the user to add it to portfolio.md by running ` +
|
|
42
|
+
`/add-to-portfolio ${file}.`,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
process.exit(0);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-to-portfolio
|
|
3
|
+
description: Capture a new screen/page and add it to portfolio.md. Use when a new user-facing screen, page, or route is created and should be added to the portfolio. Invoke as /add-to-portfolio <screen-file-path>.
|
|
4
|
+
argument-hint: "<screen-file-path>"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Add a screen to portfolio.md
|
|
8
|
+
|
|
9
|
+
You are given a screen file path (as an argument, or surfaced by the new-screen
|
|
10
|
+
hook). Goal: capture the **rendered** screen and create/append it in
|
|
11
|
+
portfolio.md. The `portfolio-capture` CLI does the deterministic capture+upload;
|
|
12
|
+
your job is the judgment around it.
|
|
13
|
+
|
|
14
|
+
1. **Inspect the file** and determine:
|
|
15
|
+
- The framework and the **route URL path** it serves:
|
|
16
|
+
- Next app router: `app/foo/page.tsx` → `/foo` (strip `(group)` segments)
|
|
17
|
+
- Next pages router: `pages/foo.tsx` → `/foo`
|
|
18
|
+
- Expo Router: similar to app router
|
|
19
|
+
- If it is a non-route component (no URL), there's nothing to navigate to —
|
|
20
|
+
skip to step 5 (manual capture).
|
|
21
|
+
- A short **title** and one-line **description** inferred from the component.
|
|
22
|
+
|
|
23
|
+
2. **App dev URL** — default `http://localhost:3000` (or `$APP_DEV_URL`). If the
|
|
24
|
+
project's dev script implies another port, use it. Confirm the dev server is
|
|
25
|
+
running; if not, ask the user to start it.
|
|
26
|
+
|
|
27
|
+
3. **Pick a target** — run `npx portfolio-capture list-projects` to show options.
|
|
28
|
+
Default to a new project unless the user wants an existing project or a view.
|
|
29
|
+
|
|
30
|
+
4. **Capture**:
|
|
31
|
+
```bash
|
|
32
|
+
npx portfolio-capture capture \
|
|
33
|
+
--route <path> --app-url <devUrl> \
|
|
34
|
+
--target <new|proj:ID|view:ID> \
|
|
35
|
+
--title "<title>" --description "<desc>"
|
|
36
|
+
```
|
|
37
|
+
- **Exit 0** → report the printed portfolio.md link to the user. Done.
|
|
38
|
+
- **Exit 3** (not renderable — auth wall / blank page) → go to step 5.
|
|
39
|
+
|
|
40
|
+
5. **Auth wall?** If exit 3 was because the route needs login, suggest running
|
|
41
|
+
`npx portfolio-capture login --app-url <devUrl>` once — it opens a browser to
|
|
42
|
+
log in and saves the session to `./.portfolio-auth.json`, which `capture`
|
|
43
|
+
then picks up automatically. Re-run step 4 after.
|
|
44
|
+
|
|
45
|
+
6. **Manual fallback** — if it still can't render (or it's a non-route
|
|
46
|
+
component), tell the user to open `<devUrl><path>` and capture it with the
|
|
47
|
+
**snapshot-tray** overlay or the **Chrome extension** into the chosen target.
|
|
48
|
+
|
|
49
|
+
Never fabricate a screenshot or a link — only report a portfolio.md link after a
|
|
50
|
+
real exit-0 capture.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: portfolio-sync
|
|
3
|
+
description: Find newly added screens/pages not yet in portfolio.md and offer to add each one. Use to sweep hand-coded screens that the new-screen hook missed (the hook only fires on Claude's own edits). Invoke as /portfolio-sync.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Sync new screens to portfolio.md
|
|
7
|
+
|
|
8
|
+
The new-screen hook only fires on screens **Claude** creates. This skill catches
|
|
9
|
+
screens you hand-coded in your editor.
|
|
10
|
+
|
|
11
|
+
1. **Find candidate screens.** Run `git diff --name-only main...HEAD` plus
|
|
12
|
+
`git status --porcelain`, then keep only paths matching screen patterns:
|
|
13
|
+
`app/**/{page,layout}.tsx`, `pages/**/*.tsx`, `src/screens/**/*.tsx`,
|
|
14
|
+
Expo `app/**/*.tsx`. Exclude `components/`, `*.test.*`, `*.stories.*`, and
|
|
15
|
+
files starting with `_`.
|
|
16
|
+
|
|
17
|
+
2. **Confirm with the user.** Show the candidate list. They pick which to add.
|
|
18
|
+
|
|
19
|
+
3. **Add each confirmed screen** by following the **add-to-portfolio** skill
|
|
20
|
+
(inspect → route → target → `portfolio-capture capture …`, with the manual
|
|
21
|
+
fallback on exit 3).
|
|
22
|
+
|
|
23
|
+
4. **Summarize**: list what was added (with portfolio.md links) and what was
|
|
24
|
+
skipped.
|