From 19c979e4e0a90050f126f64efe636b04663d354b Mon Sep 17 00:00:00 2001 From: heznpc Date: Wed, 10 Jun 2026 05:47:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20agent=20contract=20=E2=80=94=20--json?= =?UTF-8?q?=20output,=20path=20positional,=20exit=20codes,=20capture=20ski?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes shotkit first-class for shell-having coding agents WITHOUT an MCP server (per the surface-by-nature decision: shotkit is a heavy, file-producing build tool, so MCP is dropped from the roadmap → Non-goals). - bin/shotkit.js: --json mode (stdout = exactly one {ok, outDir, produced[]} object; progress logs → stderr; build stdout → stderr), optional path positional (run against any checkout without cd), exit codes 0/1/2 (2 = usage/no config) - src/cli.js: arg parsing + config resolution extracted from bin and unit-tested (25 tests total) - src/capture.js: returns {produced, outDir}; build stdio routed by json mode - skills/capture/SKILL.md: Claude Code skill wrapping the CLI (Agent Skills format; shipped in the npm tarball via files) - AGENTS.md: 'Run this tool (for agents)' block (read by Claude Code/Codex/Cursor/Gemini CLI/...) - README/README.ko: agent-contract docs; MCP row dropped with rationale; install honesty note (npm publish pending → github:#v1.1.0) - package.json: v1.1.0; remove broken version:* script references; files += skills --- AGENTS.md | 36 ++++++++++++++++---- README.ko.md | 35 +++++++++++++------- README.md | 49 +++++++++++++++++++-------- bin/shotkit.js | 73 +++++++++++++++-------------------------- package.json | 13 ++++---- skills/capture/SKILL.md | 40 ++++++++++++++++++++++ src/capture.js | 8 +++-- src/cli.js | 73 +++++++++++++++++++++++++++++++++++++++++ test/cli.test.js | 52 +++++++++++++++++++++++++++++ 9 files changed, 291 insertions(+), 88 deletions(-) create mode 100644 skills/capture/SKILL.md create mode 100644 src/cli.js create mode 100644 test/cli.test.js diff --git a/AGENTS.md b/AGENTS.md index 856270e..5e49515 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,25 @@ # shotkit A Playwright capture engine for store/social assets, used via the `shotkit` CLI, -`capture()` programmatically, or (planned) an MCP tool. Vanilla JS, CommonJS, no -build step. +`capture()` programmatically, or the `skills/capture/` Claude Code skill. +Vanilla JS, CommonJS, no build step. + +## Run this tool (for agents) + +To capture store/social assets from a repo that has a `shotkit.config.js` +(or legacy `store.config.js`): + +```bash +npx @starter-series/shotkit --json # all assets; stdout = {ok, outDir, produced[]} +npx @starter-series/shotkit --json # run against another checkout +``` + +Prereqs: `npx playwright install chromium` (one-time); the config's `build` +command must succeed. Loading an MV3 extension needs a **headed** Chromium — +works as-is locally, `xvfb-run` in CI. Exit codes: `0` ok · `1` runtime +failure · `2` usage / no config. In `--json` mode progress logs go to stderr; +stdout is exactly one JSON object. Useful flags: `--scene `, +`--no-video`, `--no-build`. ## Structure @@ -16,8 +33,10 @@ src/ promo.js → renderPromoTile (HTML template → image) describe.js → extractListing / renderDescriptionDoc (STORE_LISTING.md → copy) presets.js → PRESETS / resolveSize (CWS + SNS sizes) + cli.js → CLI arg parsing + config resolution (unit-tested) index.js → public API (the contract — don't break exports) -bin/shotkit.js → CLI (thin wrapper over capture()) +bin/shotkit.js → CLI (thin wrapper over capture(); --json agent contract) +skills/capture/ → Claude Code skill wrapping the CLI (Agent Skills format) test/ → unit tests for the pure/safe modules (no browser) ``` @@ -41,11 +60,14 @@ test/ → unit tests for the pure/safe modules (no browser) ## Generalization rule (for the next starter-series capability) One npm package (engine + thin CLI), one `*.config.js` seam for irreducible -per-repo intent, one MCP tool taking a `path`, one Claude Code skill, one -marketplace entry. **The engine never reads project specifics except through the -config seam.** shotkit is the reference implementation of this pattern; mirror +per-repo intent, **agent surfaces matched to the tool's nature** — fast / +structured-data tools get an MCP tool taking a `path` (like `create-starter`'s +audits); heavy, file-producing build tools like shotkit get a `--json` CLI + +Claude Code skill + AGENTS.md run-block instead — plus one marketplace entry. +**The engine never reads project specifics except through the config seam.** +shotkit is the reference implementation of the non-MCP branch; mirror [`create-starter`](https://github.com/starter-series/create-starter) for the -CLI+MCP+plugin surfaces when adding them. +MCP branch. ## Dev diff --git a/README.ko.md b/README.ko.md index 9ab252c..954bc74 100644 --- a/README.ko.md +++ b/README.ko.md @@ -19,10 +19,10 @@ ## 상태와 범위 (Status & Scope) -- **현재 구현된 것** — Playwright 캡처 **엔진**(빌드 → `--load-extension`으로 *빌드된* 익스텐션 로드 → scene 구동 → 스크린샷 → 캡션/면책 밴드 → HTML 프로모 타일 → 데모 `webm` → `STORE_LISTING.md`에서 문안 추출), **CLI**(`shotkit`), 양쪽 용도 **사이즈 프리셋**(CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), **path-traversal 안전** 로컬 픽스처 서버, 프로그램 API(`capture()`). `browser-extension-starter`·`skillBridge`가 소비. -- **계획된 것** — **MCP stdio 도구**(`capture_assets({ path })`) — 에이전트가 어느 체크아웃이든 repo당 설치 없이 캡처 실행; **Claude Code skill/command**(`/capture`); `starter-series` 플러그인 **마켓플레이스** 등재; **동영상 편집**(`webm → mp4`, 트림, 캡션); OIDC npm publish. -- **설계 의도** — *엔진 1개, 표면 여러 개* ([`create-starter`](https://github.com/starter-series/create-starter)가 이미 증명한 패턴: CLI + MCP + skill). 캡처는 **결정적**(로그인 불필요 픽스처, freeze된 데이터)이고, 실행이 **실제 빌드본 smoke test를 겸함** — 스크린샷이 나온다 = 그 기능이 출하 코드에서 렌더됨. 모든 샷에 면책 밴드를 합성해 **상표 안전**. -- **하지 않기로 한 것** — repo별 **scene 설정** 제거(어떤 화면이 *당신의* money shot인지는 환원 불가한 의도 — `shotkit.config.js`에 둠). 범용 동영상 편집기(v1은 깔끔한 녹화만; 편집은 계획). 호스티드 서비스(파일을 만지는 캡처는 본질적으로 로컬/stdio). +- **현재 구현된 것** — Playwright 캡처 **엔진**(빌드 → `--load-extension`으로 *빌드된* 익스텐션 로드 → scene 구동 → 스크린샷 → 캡션/면책 밴드 → HTML 프로모 타일 → 데모 `webm` → `STORE_LISTING.md`에서 문안 추출), **에이전트 계약**을 갖춘 **CLI**(`shotkit` — `--json` 머신 출력, 선택적 `path` 인자, `0/1/2` 종료 코드), 양쪽 용도 **사이즈 프리셋**(CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), **path-traversal 안전** 로컬 픽스처 서버, 프로그램 API(`capture()`), **Claude Code skill**([`skills/capture/`](skills/capture/SKILL.md)), 그리고 셸을 가진 어떤 코딩 에이전트든 호출법을 읽을 수 있는 **AGENTS.md 실행 블록**. `browser-extension-starter`·`skillBridge`가 소비. +- **계획된 것** — npm publish(그 전까지는 `github:starter-series/shotkit#v1.1.0`로 설치); **capture-in-CI GitHub Action**(공식 Playwright 이미지 + `xvfb`로 캡처를 CI에서 돌리고 `store-assets/`를 artifact로 업로드 — 로컬 브라우저 0); `starter-series` 플러그인 **마켓플레이스** 등재; **동영상 편집**(`webm → mp4`, 트림, 캡션). +- **설계 의도** — *엔진 1개, 표면 여러 개 — 단, 도구 성격에 맞는 표면.* shotkit은 무겁고 파일을 산출하는 빌드 도구라 표면이 CLI(+`--json`)·skill·CI입니다 — MCP가 아니라(하지 않기로 한 것 참고). 캡처는 **결정적**(로그인 불필요 픽스처, freeze된 데이터)이고, 실행이 **실제 빌드본 smoke test를 겸함** — 스크린샷이 나온다 = 그 기능이 출하 코드에서 렌더됨. 모든 샷에 면책 밴드를 합성해 **상표 안전**. +- **하지 않기로 한 것** — **MCP 서버**(의도적으로 폐기: 셸이 있는 에이전트에는 `--json` + skill이 세션당 컨텍스트 비용 없이 더 나은 계약이며, 여기엔 빠른 구조화 질의가 없음). repo별 **scene 설정** 제거(어떤 화면이 *당신의* money shot인지는 환원 불가한 의도 — `shotkit.config.js`에 둠). 범용 동영상 편집기(v1은 깔끔한 녹화만; 편집은 계획). 호스티드 서비스(파일을 만지는 캡처는 본질적으로 로컬). - **공개하지 않음** — 없음. ## 설치 @@ -32,7 +32,10 @@ npm i -D @starter-series/shotkit npx playwright install chromium # 최초 1회: shotkit이 구동할 브라우저 ``` -설정 파일이 있는 repo면 무설치 실행: +> npm publish는 준비 중입니다 — 그 전까지는 GitHub에서 설치하십시오: +> `npm i -D github:starter-series/shotkit#v1.1.0` + +게시 후에는 설정 파일이 있는 repo면 무설치 실행이 가능합니다: ```bash npx @starter-series/shotkit @@ -45,19 +48,29 @@ npx @starter-series/shotkit `shotkit.config.js`(repo별 이음새 — 영문 README의 contract 참고)를 두고: ```bash -shotkit # outDir에 전부 산출 -shotkit --scene 01-feature # 특정 scene/타일/데모 또는 "description"만 -shotkit --no-video # 스크린캐스트 생략 -shotkit --no-build # 이미 빌드된 번들 사용 +shotkit # outDir에 전부 산출 +shotkit --scene 01-feature # 특정 scene/타일/데모 또는 "description"만 +shotkit --no-video # 스크린캐스트 생략 +shotkit --no-build # 이미 빌드된 번들 사용 +shotkit ../my-extension --json # 다른 체크아웃 대상 실행; 결과 JSON을 stdout에 ``` 산출물은 `outDir`(기본 `store-assets/`): `.png`, `.png`, `.webm`, `description.md`. +### 에이전트 계약 (`--json`) + +`shotkit [path] --json`은 stdout에 **정확히 하나의 JSON 객체**를 출력합니다 +(진행 로그는 stderr로 이동): `{ "ok": true, "outDir": …, "produced": [절대경로…] }`. +종료 코드: `0` 정상 · `1` 런타임 실패(stderr에 `{"ok":false,"error":…}`) · +`2` 사용법 오류/설정 없음. 에이전트 연결은 [`AGENTS.md`](AGENTS.md) 실행 블록 +(Claude Code·Codex·Cursor·Gemini CLI 등이 읽음)과 [`skills/capture/`](skills/capture/SKILL.md) +skill(Agent Skills 표준 — 호환 도구의 skills 디렉터리에 폴더째 복사)을 참고하십시오. + ## 로드맵 — 엔진 1개, 표면 여러 개 -CLI(✅) · `capture()`(✅) · **MCP stdio 도구**(계획, 에이전트용) · Claude Code skill(계획) · 마켓플레이스 등재(계획) · 동영상 편집(계획). +CLI `--json`+`path`(✅) · `capture()`(✅) · Claude Code skill(✅) · AGENTS.md 실행 블록(✅) · npm publish(계획) · capture-in-CI GitHub Action(계획) · 마켓플레이스 등재(계획) · 동영상 편집(계획). MCP stdio 도구는 검토 후 **폐기** — "하지 않기로 한 것" 참고. -**일반화 규칙**(시리즈의 다음 기능용): npm 패키지 1개(엔진+얇은 CLI) + `*.config.js` 이음새 1개 + `path` 받는 MCP 도구 1개 + Claude Code skill 1개 + 마켓플레이스 항목 1개. **엔진은 config 이음새 외엔 프로젝트 특이사항을 읽지 않는다.** +**일반화 규칙**(시리즈의 다음 기능용): npm 패키지 1개(엔진+얇은 CLI) + `*.config.js` 이음새 1개 + **도구 성격에 맞는 에이전트 표면**(빠른 구조화 도구: `path` 받는 MCP 도구 / 무거운 빌드 도구: `--json` CLI + skill + AGENTS.md 블록) + 마켓플레이스 항목 1개. **엔진은 config 이음새 외엔 프로젝트 특이사항을 읽지 않는다.** ## 라이선스 diff --git a/README.md b/README.md index a759ce3..2baf64a 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ Screenshots · promo images · demo screencast · listing copy. One command. ## Status & Scope -- **Currently implemented** — A Playwright capture **engine** (build → launch the *built* extension via `launchPersistentContext(--load-extension)` → drive scenes → screenshot → caption/disclaimer band → promo tile from HTML → demo `webm` → listing copy from `STORE_LISTING.md`), a **CLI** (`shotkit`), **size presets** for both audiences (CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), a **path-traversal-safe** localhost fixture server, and a programmatic API (`capture()`). Consumed by `browser-extension-starter` and `skillBridge`. -- **Planned** — an **MCP stdio tool** (`capture_assets({ path })`) so an agent can run captures against any checkout with zero per-repo install; a **Claude Code skill/command** (`/capture`); a listing in the `starter-series` plugin **marketplace**; **video editing** (`webm → mp4`, trim, captions) for SNS; npm publish via OIDC. -- **Design intent** — *One engine, many surfaces* (the pattern [`create-starter`](https://github.com/starter-series/create-starter) already proves: a single engine fronted by CLI + MCP + skill). Captures are **deterministic** (login-free fixtures, frozen data) and the run **doubles as a real-bundle smoke test** — a screenshot only appears if that feature rendered from the shipped code. **Trademark-safe** by construction: a disclaimer band is composited onto every shot. -- **Non-goals** — Removing the per-repo **scene config** (which screens are *your* money shots is irreducible intent — it lives in your `shotkit.config.js`). A general-purpose video editor (v1 records a clean screencast; editing is Planned). A hosted service (file-touching capture is local/stdio by nature). +- **Currently implemented** — A Playwright capture **engine** (build → launch the *built* extension via `launchPersistentContext(--load-extension)` → drive scenes → screenshot → caption/disclaimer band → promo tile from HTML → demo `webm` → listing copy from `STORE_LISTING.md`), a **CLI** (`shotkit`) with an **agent contract** (`--json` machine output, optional `path` argument, `0/1/2` exit codes), **size presets** for both audiences (CWS `1280×800`/`440×280`, SNS `1200×675`/`1200×630`/`1080×1080`), a **path-traversal-safe** localhost fixture server, a programmatic API (`capture()`), a **Claude Code skill** ([`skills/capture/`](skills/capture/SKILL.md)), and an **AGENTS.md run-block** so any shell-having coding agent can invoke it. Consumed by `browser-extension-starter` and `skillBridge`. +- **Planned** — npm publish (until then install via `github:starter-series/shotkit#v1.1.0`); a **capture-in-CI GitHub Action** (run the capture under `xvfb` on the official Playwright image and upload `store-assets/` as an artifact — zero local browser); a listing in the `starter-series` plugin **marketplace**; **video editing** (`webm → mp4`, trim, captions) for SNS. +- **Design intent** — *One engine, many surfaces — matched to the tool's nature.* shotkit is a heavy, file-producing build tool, so its surfaces are CLI (+`--json`), skill, and CI — not MCP (see Non-goals). Captures are **deterministic** (login-free fixtures, frozen data) and the run **doubles as a real-bundle smoke test** — a screenshot only appears if that feature rendered from the shipped code. **Trademark-safe** by construction: a disclaimer band is composited onto every shot. +- **Non-goals** — An **MCP server** (dropped by design: agents with a shell get a better contract from `--json` + the skill, without MCP's per-session context cost; nothing here is a fast structured query). Removing the per-repo **scene config** (which screens are *your* money shots is irreducible intent — it lives in your `shotkit.config.js`). A general-purpose video editor (v1 records a clean screencast; editing is Planned). A hosted service (file-touching capture is local by nature). - **Redacted** — none. Ships no private data, credentials, or third-party identifiers. ## Install @@ -34,7 +34,10 @@ npm i -D @starter-series/shotkit npx playwright install chromium # one-time: the browser shotkit drives ``` -Or zero-install in any repo that has a config: +> npm publish is pending — until it lands, install from GitHub: +> `npm i -D github:starter-series/shotkit#v1.1.0` + +Once published, zero-install works in any repo that has a config: ```bash npx @starter-series/shotkit @@ -47,14 +50,30 @@ npx @starter-series/shotkit Add a `shotkit.config.js` (the per-repo seam — see the contract below), then: ```bash -shotkit # produce everything into outDir -shotkit --scene 01-feature # just one scene/promoTile/demo, or "description" -shotkit --no-video # skip the screencast (faster/CI) -shotkit --no-build # use an already-built bundle +shotkit # produce everything into outDir +shotkit --scene 01-feature # just one scene/promoTile/demo, or "description" +shotkit --no-video # skip the screencast (faster/CI) +shotkit --no-build # use an already-built bundle +shotkit ../my-extension --json # run against another checkout; JSON result on stdout ``` Outputs land in `outDir` (default `store-assets/`): `.png`, `.png`, `.webm`, `description.md`. +### Agent contract (`--json`) + +`shotkit [path] --json` prints **exactly one JSON object** to stdout (progress +logs move to stderr): + +```json +{ "ok": true, "outDir": "/abs/store-assets", "produced": ["/abs/store-assets/01-popup.png"] } +``` + +Exit codes: `0` ok · `1` runtime failure (stderr carries `{"ok":false,"error":…}`) · +`2` usage / no config found. Drop-in agent wiring: the run-block in +[`AGENTS.md`](AGENTS.md) (read by Claude Code, Codex, Cursor, Gemini CLI, …) and +the [`skills/capture/`](skills/capture/SKILL.md) skill (Agent Skills format — +copy the folder into any compatible tool's skills directory). + ## Config contract (`shotkit.config.js`) ```js @@ -99,14 +118,18 @@ module.exports = { | Surface | Status | For | |---|---|---| -| CLI (`shotkit`, `npx`) | ✅ now | humans / CI, zero-install | +| CLI (`shotkit`, `npx`) with `--json` + `path` | ✅ now | humans / CI / **shell-having agents** | | Programmatic `capture()` | ✅ now | embedding | -| **MCP stdio tool** `capture_assets({ path })` | planned | **agents, any repo, no install** | -| Claude Code skill/command `/capture` | planned | Claude Code users | +| Claude Code skill ([`skills/capture/`](skills/capture/SKILL.md)) | ✅ now | Claude Code (portable to Codex/Cursor/Gemini via the Agent Skills format) | +| `AGENTS.md` run-block | ✅ now | every agent that reads AGENTS.md | +| npm publish | planned | `npx` zero-install | +| Capture-in-CI GitHub Action (xvfb + artifact) | planned | zero-local-browser first run + CI smoke test | | `starter-series` marketplace entry | planned | discovery | | Video editing (`webm→mp4`, trim, captions) | planned | SNS clips | -**Generalization rule** (for the next capability in the series): *one npm package (engine + thin CLI), one `*.config.js` seam for irreducible per-repo intent, one MCP tool taking a `path`, one Claude Code skill, one marketplace entry. The engine never reads project specifics except through the config seam.* +An MCP stdio tool was considered and **dropped** — see Non-goals: shotkit is a heavy, file-producing build tool, so a `--json` CLI + skill serves agents better than an MCP server's per-session context cost. + +**Generalization rule** (for the next capability in the series): *one npm package (engine + thin CLI), one `*.config.js` seam for irreducible per-repo intent, agent surfaces matched to the tool's nature (fast/structured: an MCP tool taking a `path`; heavy/build-time: a `--json` CLI + skill + AGENTS.md run-block), one marketplace entry. The engine never reads project specifics except through the config seam.* ## License diff --git a/bin/shotkit.js b/bin/shotkit.js index c2e847f..ffc116a 100644 --- a/bin/shotkit.js +++ b/bin/shotkit.js @@ -2,55 +2,23 @@ /* * shotkit CLI — thin wrapper over capture(). * - * shotkit [--config ] [--scene ]... [--no-video] [--no-build] - * [--live-gt] [--freeze] + * shotkit [path] [--config ] [--scene ]... [--json] + * [--no-video] [--no-build] [--live-gt] [--freeze] * - * Looks for a config at --config, else shotkit.config.js, else store.config.js - * (back-compat) in the current directory. + * `path` (optional positional) is the repo to run against (default: cwd) — + * lets an agent invoke shotkit against any checkout without cd'ing first. + * Config resolution: --config, else shotkit.config.js, else store.config.js + * (back-compat) inside that directory. + * + * Exit codes: 0 ok · 1 runtime failure · 2 usage / no config found. + * With --json, stdout carries exactly one JSON object and progress logs go + * to stderr, so agents can parse stdout blindly. */ const fs = require('fs'); const path = require('path'); const { capture } = require('../src'); - -const USAGE = `shotkit — capture store/social assets from a built extension - -Usage: shotkit [options] - -Options: - --config config file (default: shotkit.config.js | store.config.js) - --scene only capture this scene/promoTile/demo, or "description"; - repeatable, or comma-separated. When given, nothing else runs. - --no-video skip the demo screencast - --no-build skip the config's build step (use an already-built bundle) - --live-gt pass flags.liveGt to config hooks - --freeze pass flags.freeze to config hooks - -h, --help show this help -`; - -function parseArgs(argv) { - const opts = { scenes: [], noVideo: false, noBuild: false, liveGt: false, freeze: false, config: null, help: false }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--scene') opts.scenes.push(...(argv[++i] || '').split(',').filter(Boolean)); - else if (a === '--config') opts.config = argv[++i]; - else if (a === '--no-video') opts.noVideo = true; - else if (a === '--no-build') opts.noBuild = true; - else if (a === '--live-gt') opts.liveGt = true; - else if (a === '--freeze') opts.freeze = true; - else if (a === '-h' || a === '--help') opts.help = true; - } - return opts; -} - -function resolveConfigPath(explicit, cwd) { - if (explicit) return path.resolve(cwd, explicit); - for (const name of ['shotkit.config.js', 'store.config.js']) { - const p = path.join(cwd, name); - if (fs.existsSync(p)) return p; - } - return null; -} +const { parseArgs, resolveConfigPath, USAGE } = require('../src/cli'); async function main() { const opts = parseArgs(process.argv.slice(2)); @@ -58,16 +26,27 @@ async function main() { process.stdout.write(USAGE); return; } - const cwd = process.cwd(); + const cwd = path.resolve(process.cwd(), opts.path || '.'); const configPath = resolveConfigPath(opts.config, cwd); if (!configPath || !fs.existsSync(configPath)) { - throw new Error(`No config found (looked for shotkit.config.js / store.config.js in ${cwd}). Pass --config .`); + const msg = `No config found (looked for shotkit.config.js / store.config.js in ${cwd}). Pass --config .`; + if (opts.json) process.stderr.write(JSON.stringify({ ok: false, error: msg, code: 2 }) + '\n'); + else console.error(`[shotkit] ${msg}`); + process.exit(2); } const config = require(configPath); - await capture(config, { ...opts, cwd }); + // In JSON mode, route human-readable progress to stderr so stdout stays pure. + const log = opts.json ? (m) => process.stderr.write(`[shotkit] ${m}\n`) : undefined; + const { produced, outDir } = await capture(config, { ...opts, cwd, log }); + if (opts.json) process.stdout.write(JSON.stringify({ ok: true, outDir, produced }) + '\n'); } main().catch((err) => { - console.error('[shotkit] FAILED:', err && err.stack ? err.stack : err); + const msg = err && err.message ? err.message : String(err); + if (process.argv.includes('--json')) { + process.stderr.write(JSON.stringify({ ok: false, error: msg, code: 1 }) + '\n'); + } else { + console.error('[shotkit] FAILED:', err && err.stack ? err.stack : err); + } process.exit(1); }); diff --git a/package.json b/package.json index 02e381c..23e886e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@starter-series/shotkit", - "version": "1.0.0", + "version": "1.1.0", "description": "Capture Chrome Web Store + social promo assets (screenshots, promo images, demo video) from a built browser extension with Playwright.", "main": "src/index.js", "exports": { @@ -11,15 +11,13 @@ }, "files": [ "src", - "bin" + "bin", + "skills" ], "scripts": { "lint": "eslint src/ bin/ test/", "test": "jest --verbose --coverage", - "install:browser": "playwright install chromium", - "version:patch": "node scripts/bump-version.js patch", - "version:minor": "node scripts/bump-version.js minor", - "version:major": "node scripts/bump-version.js major" + "install:browser": "playwright install chromium" }, "repository": { "type": "git", @@ -53,7 +51,8 @@ "src/presets.js", "src/describe.js", "src/extension.js", - "src/serve.js" + "src/serve.js", + "src/cli.js" ], "coverageReporters": [ "text", diff --git a/skills/capture/SKILL.md b/skills/capture/SKILL.md new file mode 100644 index 0000000..ee3059b --- /dev/null +++ b/skills/capture/SKILL.md @@ -0,0 +1,40 @@ +--- +name: capture +description: Capture Chrome Web Store + social promo assets (screenshots, promo tiles, demo screencast, listing copy) from a built browser extension using shotkit. Use when asked to generate store screenshots, CWS assets, promo/OG images, or a demo video for a repo that has a shotkit.config.js (or store.config.js). +allowed-tools: Bash(npx @starter-series/shotkit*), Bash(npm run capture:store*), Bash(npx playwright install chromium), Read +--- + +# Capture store/social assets with shotkit + +shotkit drives the repo's **built** extension with Playwright and writes assets +into the config's `outDir` (default `store-assets/`). A successful run doubles +as a real-bundle smoke test — a screenshot only appears if that feature +rendered from the shipped code. + +## Steps + +1. **Preconditions** — the repo has a `shotkit.config.js` (or legacy + `store.config.js`); Chromium is installed (`npx playwright install chromium`, + one-time); the config's `build` command succeeds. +2. **Run** (from the repo, or pass its path): + + ```bash + npx @starter-series/shotkit --json + npx @starter-series/shotkit --json # against another checkout + ``` + + Useful flags: `--scene ` (one scene/promoTile/demo or `description`), + `--no-video` (skip the screencast), `--no-build` (reuse an existing build). +3. **Read the result** — stdout is exactly one JSON object: + `{ "ok": true, "outDir": "...", "produced": ["/abs/path/01-….png", …] }`. + Progress logs go to stderr in `--json` mode. +4. **On failure** — exit code `2` = no config found, `1` = runtime failure; + stderr carries `{ "ok": false, "error": … }`. Common causes: build failure, + Chromium not installed, a scene's wait timing out (feature didn't render). + +## Notes + +- Loading an MV3 extension needs a **headed** Chromium: works as-is locally; + use `xvfb-run` in CI. +- Scenes are the repo's own config — to change *what* is captured, edit + `shotkit.config.js`, not shotkit. diff --git a/src/capture.js b/src/capture.js index 56a62fb..d5a900e 100644 --- a/src/capture.js +++ b/src/capture.js @@ -47,7 +47,7 @@ function normalizeSetup(result) { * @param {boolean} [opts.freeze] passed to config hooks as flags.freeze * @param {string} [opts.cwd] project root for build / outDir / description.from * @param {(msg:string)=>void} [opts.log] - * @returns {Promise<{produced: string[]}>} + * @returns {Promise<{produced: string[], outDir: string}>} */ async function capture(config, opts = {}) { const cwd = opts.cwd || process.cwd(); @@ -68,7 +68,9 @@ async function capture(config, opts = {}) { // a shell so projects can write `npm run build:bundle`; never user input. if (config.build && !opts.noBuild) { log(`build: ${config.build}`); - execSync(config.build, { stdio: 'inherit', cwd }); + // In --json mode stdout must stay a single JSON object, so route the build + // command's stdout to our stderr (fd 2) instead of inheriting it. + execSync(config.build, { stdio: opts.json ? ['ignore', 2, 2] : 'inherit', cwd }); } // 2. Prepare the unpacked extension dir to load. @@ -183,7 +185,7 @@ async function capture(config, opts = {}) { for (const d of tempDirs) fs.rmSync(d, { recursive: true, force: true }); log(`done — ${produced.length} asset(s) in ${path.relative(cwd, outDir) || '.'}/`); - return { produced }; + return { produced, outDir }; } module.exports = { capture, DEFAULT_VIEWPORT }; diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..852a6c5 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,73 @@ +/* + * shotkit — CLI argument parsing, separated from bin/ so it's unit-testable. + * + * Agent contract (the reason --json exists): with --json, stdout carries + * exactly ONE JSON object and all progress logs move to stderr, so a coding + * agent can `JSON.parse(stdout)` blindly. Exit codes are part of the same + * contract: 0 ok · 1 runtime failure · 2 usage / no config found. + */ + +const fs = require('fs'); +const path = require('path'); + +const USAGE = `shotkit — capture store/social assets from a built extension + +Usage: shotkit [path] [options] + +Arguments: + path repo to run against (default: current directory); + its shotkit.config.js / store.config.js is used + +Options: + --config config file (default: shotkit.config.js | store.config.js) + --scene only capture this scene/promoTile/demo, or "description"; + repeatable, or comma-separated. When given, nothing else runs. + --json machine-readable mode: stdout gets one JSON object + {ok, outDir, produced[]}; progress logs move to stderr + --no-video skip the demo screencast + --no-build skip the config's build step (use an already-built bundle) + --live-gt pass flags.liveGt to config hooks + --freeze pass flags.freeze to config hooks + -h, --help show this help + +Exit codes: 0 ok · 1 runtime failure · 2 usage / no config found +`; + +function parseArgs(argv) { + const opts = { + scenes: [], + noVideo: false, + noBuild: false, + liveGt: false, + freeze: false, + config: null, + json: false, + help: false, + path: null, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--scene') opts.scenes.push(...(argv[++i] || '').split(',').filter(Boolean)); + else if (a === '--config') opts.config = argv[++i]; + else if (a === '--json') opts.json = true; + else if (a === '--no-video') opts.noVideo = true; + else if (a === '--no-build') opts.noBuild = true; + else if (a === '--live-gt') opts.liveGt = true; + else if (a === '--freeze') opts.freeze = true; + else if (a === '-h' || a === '--help') opts.help = true; + else if (!a.startsWith('-') && opts.path === null) opts.path = a; + } + return opts; +} + +/** Resolve the config file inside `cwd`: --config wins, else the two defaults. */ +function resolveConfigPath(explicit, cwd) { + if (explicit) return path.resolve(cwd, explicit); + for (const name of ['shotkit.config.js', 'store.config.js']) { + const p = path.join(cwd, name); + if (fs.existsSync(p)) return p; + } + return null; +} + +module.exports = { parseArgs, resolveConfigPath, USAGE }; diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..f05deab --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,52 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { parseArgs, resolveConfigPath, USAGE } = require('../src/cli'); + +describe('parseArgs', () => { + test('defaults', () => { + expect(parseArgs([])).toMatchObject({ scenes: [], json: false, noVideo: false, help: false, path: null }); + }); + + test('positional path + flags', () => { + const o = parseArgs(['../my-ext', '--json', '--no-video']); + expect(o.path).toBe('../my-ext'); + expect(o.json).toBe(true); + expect(o.noVideo).toBe(true); + }); + + test('--scene accepts comma lists and repeats', () => { + expect(parseArgs(['--scene', 'a,b', '--scene', 'c']).scenes).toEqual(['a', 'b', 'c']); + }); + + test('--config consumes its value (not mistaken for the positional)', () => { + const o = parseArgs(['--config', 'x.js', 'repo']); + expect(o.config).toBe('x.js'); + expect(o.path).toBe('repo'); + }); + + test('only the first non-flag token becomes the path', () => { + expect(parseArgs(['a', 'b']).path).toBe('a'); + }); + + test('USAGE documents the agent contract', () => { + expect(USAGE).toContain('--json'); + expect(USAGE).toContain('Exit codes'); + }); +}); + +describe('resolveConfigPath', () => { + test('prefers shotkit.config.js, falls back to store.config.js, else null', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-cli-')); + expect(resolveConfigPath(null, dir)).toBeNull(); + fs.writeFileSync(path.join(dir, 'store.config.js'), 'module.exports={}'); + expect(resolveConfigPath(null, dir)).toBe(path.join(dir, 'store.config.js')); + fs.writeFileSync(path.join(dir, 'shotkit.config.js'), 'module.exports={}'); + expect(resolveConfigPath(null, dir)).toBe(path.join(dir, 'shotkit.config.js')); + }); + + test('explicit --config resolves against the target dir', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sk-cli-')); + expect(resolveConfigPath('custom.js', dir)).toBe(path.resolve(dir, 'custom.js')); + }); +});