@pixelhop/dit 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/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # @pixelhop/dit
2
+
3
+ The `dit` CLI for [Did It Though?](https://diditthough.app) — visual proof for agent-built
4
+ software. An agent uploads the screenshots and screen recordings it produced, gets back
5
+ PR-ready Markdown, and later reads the humans' annotations as structured JSON.
6
+
7
+ Node 22+. No runtime dependencies.
8
+
9
+ ```bash
10
+ npx @pixelhop/dit --help
11
+ # or install it once
12
+ npm install -g @pixelhop/dit
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Two values, from flags or the environment:
18
+
19
+ | Flag | Environment | Meaning |
20
+ | --------------- | ----------- | ----------------------------------------------------------- |
21
+ | `--url <url>` | `DIT_URL` | API base URL — `https://diditthough.app` for the hosted app |
22
+ | `--token <tok>` | `DIT_TOKEN` | Agent token (`dit_…`), created per project in the web UI |
23
+
24
+ Set `DIT_DEBUG=1` to print stack traces instead of one-line errors.
25
+
26
+ ```bash
27
+ export DIT_URL=https://diditthough.app
28
+ export DIT_TOKEN=dit_…
29
+ ```
30
+
31
+ The token is scoped to one project and one workspace. Agents cannot create projects — a
32
+ human makes the project and mints the token first.
33
+
34
+ ## Commands
35
+
36
+ ### `dit upload`
37
+
38
+ Uploads one or more files to a review, creating the review if it does not exist yet.
39
+
40
+ ```bash
41
+ dit upload --project marketing-site --review pr-1234 \
42
+ --title "Fix the mobile action bar" \
43
+ --commit "$(git rev-parse HEAD)" --branch "$(git branch --show-current)" \
44
+ --file shots/action-bar-mobile.png --file shots/action-bar-desktop.png
45
+ ```
46
+
47
+ `--review` takes either an existing `rev_…` id or your own reference — a PR number, a run
48
+ id, anything stable. A reference that has been seen before reuses that review, so a job
49
+ that runs twice on the same PR adds revisions instead of a second review. `--project`
50
+ takes the project slug or its `prj_…` id.
51
+
52
+ Per-capture context, all optional: `--route /checkout`, `--viewport 390x844@3`,
53
+ `--scenario "logged out"`, `--title`. Pass `--json` for the machine-readable form; the
54
+ default prints the review URL and the Markdown block to paste into a PR.
55
+
56
+ Images are sniffed by magic bytes (PNG, JPEG, WebP, GIF) and their dimensions read from
57
+ the file, so a mislabelled extension does not matter. For MP4 and WebM the CLI shells out
58
+ to `ffprobe` for duration and dimensions, and to `ffmpeg` for a poster frame — both are
59
+ optional. Without them the upload still succeeds, with a warning, minus the poster.
60
+
61
+ ### `dit feedback`
62
+
63
+ ```bash
64
+ dit feedback --review pr-1234 # open threads, human-readable
65
+ dit feedback --review pr-1234 --status all --json
66
+ ```
67
+
68
+ Threads come back with normalised 0–1 geometry and `timestampMs` for video notes, so an
69
+ agent can map a comment to the place it points at without knowing the render size.
70
+ `--status` is `open` (default), `addressed`, `resolved` or `all`.
71
+
72
+ ### `dit reply` and `dit address`
73
+
74
+ ```bash
75
+ dit reply --thread thr_… --message "Fixed the padding, re-shot below."
76
+ dit address --thread thr_… --message "Done in 4a1c9f2."
77
+ ```
78
+
79
+ `reply` adds a comment. `address` marks the thread as handled and leaves the resolve
80
+ decision to the human who raised it.
81
+
82
+ ### `dit revision`
83
+
84
+ Uploads a new take of one artifact, keeping it linked to the original and to any notes on
85
+ it.
86
+
87
+ ```bash
88
+ dit revision --artifact art_… --file shots/action-bar-mobile.png
89
+ ```
90
+
91
+ ### `dit markdown`
92
+
93
+ Reprints the PR Markdown for a whole review — every artifact, current revision, as image
94
+ links that GitHub will render inline.
95
+
96
+ ```bash
97
+ dit markdown --review pr-1234 >> pr-body.md
98
+ ```
99
+
100
+ ## Exit codes
101
+
102
+ `0` success, `2` usage error (a bad or missing flag), `1` everything else, including API
103
+ errors. Errors go to stderr as a single line.
104
+
105
+ ## Development
106
+
107
+ From the repository root:
108
+
109
+ ```bash
110
+ pnpm --filter @pixelhop/dit build # tsc → dist/
111
+ pnpm --filter @pixelhop/dit test # vitest
112
+ pnpm --filter @pixelhop/dit typecheck
113
+ ```
114
+
115
+ Releases are cut by tagging `cli-v<version>`, which runs `.github/workflows/publish-cli.yml`.
116
+ See that file for the publish path and the repository README for the service itself.
package/dist/args.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ export type Viewport = {
2
+ width: number;
3
+ height: number;
4
+ deviceScaleFactor?: number;
5
+ };
6
+ type RuntimeFlags = {
7
+ url?: string;
8
+ token?: string;
9
+ };
10
+ type JsonFlag = {
11
+ json?: true;
12
+ };
13
+ export type CliArgs = ({
14
+ command: "upload";
15
+ project: string;
16
+ review: string;
17
+ files: string[];
18
+ } & RuntimeFlags & JsonFlag & {
19
+ title?: string;
20
+ commit?: string;
21
+ branch?: string;
22
+ route?: string;
23
+ viewport?: Viewport;
24
+ scenario?: string;
25
+ }) | ({
26
+ command: "feedback";
27
+ review: string;
28
+ status: FeedbackStatus;
29
+ } & RuntimeFlags & JsonFlag) | ({
30
+ command: "reply";
31
+ thread: string;
32
+ message: string;
33
+ } & RuntimeFlags) | ({
34
+ command: "address";
35
+ thread: string;
36
+ message?: string;
37
+ } & RuntimeFlags) | ({
38
+ command: "revision";
39
+ artifact: string;
40
+ file: string;
41
+ } & RuntimeFlags & JsonFlag) | ({
42
+ command: "markdown";
43
+ review: string;
44
+ } & RuntimeFlags) | {
45
+ command: "help";
46
+ topic?: string;
47
+ } | {
48
+ command: "version";
49
+ };
50
+ export type FeedbackStatus = "open" | "addressed" | "resolved" | "all";
51
+ export declare function parseCliArgs(argv: string[]): CliArgs;
52
+ export declare function parseViewport(value: string): Viewport;
53
+ export {};
package/dist/args.js ADDED
@@ -0,0 +1,182 @@
1
+ import { parseArgs } from "node:util";
2
+ import { UsageError } from "./errors.js";
3
+ const runtimeOptions = {
4
+ url: { type: "string" },
5
+ token: { type: "string" },
6
+ help: { type: "boolean", short: "h" },
7
+ };
8
+ const commandOptions = {
9
+ upload: {
10
+ ...runtimeOptions,
11
+ project: { type: "string" },
12
+ review: { type: "string" },
13
+ file: { type: "string", multiple: true },
14
+ title: { type: "string" },
15
+ commit: { type: "string" },
16
+ branch: { type: "string" },
17
+ route: { type: "string" },
18
+ viewport: { type: "string" },
19
+ scenario: { type: "string" },
20
+ json: { type: "boolean" },
21
+ },
22
+ feedback: {
23
+ ...runtimeOptions,
24
+ review: { type: "string" },
25
+ status: { type: "string" },
26
+ json: { type: "boolean" },
27
+ },
28
+ reply: {
29
+ ...runtimeOptions,
30
+ thread: { type: "string" },
31
+ message: { type: "string" },
32
+ },
33
+ address: {
34
+ ...runtimeOptions,
35
+ thread: { type: "string" },
36
+ message: { type: "string" },
37
+ },
38
+ revision: {
39
+ ...runtimeOptions,
40
+ artifact: { type: "string" },
41
+ file: { type: "string" },
42
+ json: { type: "boolean" },
43
+ },
44
+ markdown: {
45
+ ...runtimeOptions,
46
+ review: { type: "string" },
47
+ },
48
+ };
49
+ export function parseCliArgs(argv) {
50
+ const command = argv[0];
51
+ if (!command || command === "help" || command === "--help" || command === "-h") {
52
+ return { command: "help", ...(argv[1] ? { topic: argv[1] } : {}) };
53
+ }
54
+ if (command === "--version" || command === "-v" || command === "version") {
55
+ return { command: "version" };
56
+ }
57
+ if (!(command in commandOptions)) {
58
+ throw new UsageError(`Unknown command: ${command}`);
59
+ }
60
+ const name = command;
61
+ let values;
62
+ try {
63
+ ({ values } = parseArgs({
64
+ args: argv.slice(1),
65
+ options: commandOptions[name],
66
+ strict: true,
67
+ allowPositionals: false,
68
+ }));
69
+ }
70
+ catch (error) {
71
+ throw new UsageError(error instanceof Error ? error.message : "Invalid arguments");
72
+ }
73
+ if (values.help) {
74
+ return { command: "help", topic: name };
75
+ }
76
+ const runtime = runtimeFlags(values);
77
+ if (name === "upload") {
78
+ const files = Array.isArray(values.file) ? values.file : [];
79
+ if (files.length === 0) {
80
+ throw new UsageError("upload requires at least one --file");
81
+ }
82
+ return {
83
+ command: name,
84
+ project: required(values, "project", name),
85
+ review: required(values, "review", name),
86
+ files,
87
+ ...optional(values, "title"),
88
+ ...optional(values, "commit"),
89
+ ...optional(values, "branch"),
90
+ ...optional(values, "route"),
91
+ ...(typeof values.viewport === "string" ? { viewport: parseViewport(values.viewport) } : {}),
92
+ ...optional(values, "scenario"),
93
+ ...(values.json ? { json: true } : {}),
94
+ ...runtime,
95
+ };
96
+ }
97
+ if (name === "feedback") {
98
+ const status = (values.status ?? "open");
99
+ if (!["open", "addressed", "resolved", "all"].includes(status)) {
100
+ throw new UsageError("--status must be open, addressed, resolved, or all");
101
+ }
102
+ return {
103
+ command: name,
104
+ review: required(values, "review", name),
105
+ status: status,
106
+ ...(values.json ? { json: true } : {}),
107
+ ...runtime,
108
+ };
109
+ }
110
+ if (name === "reply") {
111
+ return {
112
+ command: name,
113
+ thread: required(values, "thread", name),
114
+ message: required(values, "message", name),
115
+ ...runtime,
116
+ };
117
+ }
118
+ if (name === "address") {
119
+ return {
120
+ command: name,
121
+ thread: required(values, "thread", name),
122
+ ...optional(values, "message"),
123
+ ...runtime,
124
+ };
125
+ }
126
+ if (name === "revision") {
127
+ return {
128
+ command: name,
129
+ artifact: required(values, "artifact", name),
130
+ file: required(values, "file", name),
131
+ ...(values.json ? { json: true } : {}),
132
+ ...runtime,
133
+ };
134
+ }
135
+ return {
136
+ command: name,
137
+ review: required(values, "review", name),
138
+ ...runtime,
139
+ };
140
+ }
141
+ function required(values, key, command) {
142
+ const value = values[key];
143
+ if (typeof value !== "string" || value.trim() === "") {
144
+ throw new UsageError(`${command} requires --${key}`);
145
+ }
146
+ return value;
147
+ }
148
+ function optional(values, key) {
149
+ const value = values[key];
150
+ return typeof value === "string" ? { [key]: value } : {};
151
+ }
152
+ function runtimeFlags(values) {
153
+ const url = typeof values.url === "string" ? normalizeUrl(values.url) : undefined;
154
+ const token = typeof values.token === "string" ? values.token : undefined;
155
+ return { ...(url ? { url } : {}), ...(token ? { token } : {}) };
156
+ }
157
+ function normalizeUrl(value) {
158
+ let url;
159
+ try {
160
+ url = new URL(value);
161
+ }
162
+ catch {
163
+ throw new UsageError("--url must be an absolute HTTP(S) URL");
164
+ }
165
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
166
+ throw new UsageError("--url must be an absolute HTTP(S) URL");
167
+ }
168
+ return url.toString().replace(/\/+$/, "");
169
+ }
170
+ export function parseViewport(value) {
171
+ const match = /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/.exec(value);
172
+ if (!match) {
173
+ throw new UsageError("--viewport must use WIDTHxHEIGHT or WIDTHxHEIGHT@SCALE");
174
+ }
175
+ const width = Number(match[1]);
176
+ const height = Number(match[2]);
177
+ const scale = match[3] ? Number(match[3]) : undefined;
178
+ if (width < 1 || height < 1 || width > 100_000 || height > 100_000 || scale === 0) {
179
+ throw new UsageError("--viewport contains an out-of-range value");
180
+ }
181
+ return { width, height, ...(scale === undefined ? {} : { deviceScaleFactor: scale }) };
182
+ }
@@ -0,0 +1,21 @@
1
+ import { ApiClient } from "./client.js";
2
+ export type CatalogReview = {
3
+ id: string;
4
+ title: string;
5
+ externalRef: string | null;
6
+ status: string;
7
+ url: string;
8
+ };
9
+ export type CatalogProject = {
10
+ id: string;
11
+ name: string;
12
+ slug: string;
13
+ workspaceId: string;
14
+ reviews: CatalogReview[];
15
+ };
16
+ export type ProjectCatalog = {
17
+ projects: CatalogProject[];
18
+ };
19
+ export declare function getCatalog(client: ApiClient): Promise<ProjectCatalog>;
20
+ export declare function resolveProject(catalog: ProjectCatalog, reference: string): CatalogProject;
21
+ export declare function resolveReview(catalog: ProjectCatalog, reference: string): CatalogReview;
@@ -0,0 +1,29 @@
1
+ import { ApiError } from "./errors.js";
2
+ export async function getCatalog(client) {
3
+ return client.requestJson("/v1/projects");
4
+ }
5
+ export function resolveProject(catalog, reference) {
6
+ const idMatch = catalog.projects.find(({ id }) => id === reference);
7
+ if (idMatch)
8
+ return idMatch;
9
+ const matches = catalog.projects.filter(({ slug }) => slug === reference);
10
+ if (matches.length === 0)
11
+ throw new ApiError(`Project not found: ${reference}`, 404, "not_found");
12
+ if (matches.length > 1) {
13
+ throw new ApiError(`Project reference is ambiguous: ${reference}; use a project id`, 422, "invalid_request");
14
+ }
15
+ return matches[0];
16
+ }
17
+ export function resolveReview(catalog, reference) {
18
+ const reviews = catalog.projects.flatMap(({ reviews }) => reviews);
19
+ const idMatch = reviews.find(({ id }) => id === reference);
20
+ if (idMatch)
21
+ return idMatch;
22
+ const matches = reviews.filter(({ externalRef }) => externalRef === reference);
23
+ if (matches.length === 0)
24
+ throw new ApiError(`Review not found: ${reference}`, 404, "not_found");
25
+ if (matches.length > 1) {
26
+ throw new ApiError(`Review reference is ambiguous: ${reference}; use a review id`, 422, "invalid_request");
27
+ }
28
+ return matches[0];
29
+ }
@@ -0,0 +1,32 @@
1
+ type Fetch = typeof fetch;
2
+ export type ApiClientOptions = {
3
+ url: string;
4
+ token: string;
5
+ fetch?: Fetch;
6
+ sleep?: (milliseconds: number) => Promise<void>;
7
+ createIdempotencyKey?: () => string;
8
+ retries?: number;
9
+ };
10
+ export type JsonRequest = {
11
+ method?: string;
12
+ body?: unknown;
13
+ idempotent?: boolean;
14
+ authenticated?: boolean;
15
+ headers?: HeadersInit;
16
+ };
17
+ export declare class ApiClient {
18
+ readonly url: string;
19
+ private readonly token;
20
+ private readonly fetch;
21
+ private readonly sleep;
22
+ private readonly createIdempotencyKey;
23
+ private readonly retries;
24
+ constructor(options: ApiClientOptions);
25
+ requestJson<T = unknown>(path: string, request?: JsonRequest): Promise<T>;
26
+ requestText(path: string): Promise<string>;
27
+ /** Stream a file to a one-time upload URL without buffering it in the CLI. */
28
+ uploadFile(uploadUrl: string, filePath: string): Promise<void>;
29
+ private absoluteUrl;
30
+ private withRetries;
31
+ }
32
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,180 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { stat } from "node:fs/promises";
4
+ import { Readable } from "node:stream";
5
+ import { ApiError } from "./errors.js";
6
+ export class ApiClient {
7
+ url;
8
+ token;
9
+ fetch;
10
+ sleep;
11
+ createIdempotencyKey;
12
+ retries;
13
+ constructor(options) {
14
+ this.url = options.url.replace(/\/+$/, "");
15
+ this.token = options.token;
16
+ this.fetch = options.fetch ?? globalThis.fetch;
17
+ this.sleep =
18
+ options.sleep ??
19
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
20
+ this.createIdempotencyKey = options.createIdempotencyKey ?? (() => randomUUID());
21
+ this.retries = options.retries ?? 2;
22
+ }
23
+ async requestJson(path, request = {}) {
24
+ const idempotencyKey = request.idempotent ? this.createIdempotencyKey() : undefined;
25
+ const headers = new Headers(request.headers);
26
+ if (request.authenticated !== false) {
27
+ headers.set("authorization", `Bearer ${this.token}`);
28
+ }
29
+ if (request.body !== undefined) {
30
+ headers.set("content-type", "application/json");
31
+ }
32
+ if (idempotencyKey) {
33
+ headers.set("idempotency-key", idempotencyKey);
34
+ }
35
+ return this.withRetries(async () => {
36
+ const response = await this.fetch(this.absoluteUrl(path), {
37
+ method: request.method ?? "GET",
38
+ headers,
39
+ body: request.body === undefined ? undefined : JSON.stringify(request.body),
40
+ });
41
+ if (isTransient(response.status)) {
42
+ throw new TransientResponse(response);
43
+ }
44
+ if (!response.ok) {
45
+ throw await apiErrorFrom(response);
46
+ }
47
+ return (await readPayload(response));
48
+ });
49
+ }
50
+ async requestText(path) {
51
+ const headers = new Headers({ authorization: `Bearer ${this.token}` });
52
+ return this.withRetries(async () => {
53
+ const response = await this.fetch(this.absoluteUrl(path), { headers });
54
+ if (isTransient(response.status)) {
55
+ throw new TransientResponse(response);
56
+ }
57
+ if (!response.ok) {
58
+ throw await apiErrorFrom(response);
59
+ }
60
+ return response.text();
61
+ });
62
+ }
63
+ /** Stream a file to a one-time upload URL without buffering it in the CLI. */
64
+ async uploadFile(uploadUrl, filePath) {
65
+ const file = await stat(filePath);
66
+ let uncertainPreviousAttempt = false;
67
+ for (let attempt = 0; attempt <= this.retries; attempt += 1) {
68
+ try {
69
+ const response = await this.fetch(this.absoluteUrl(uploadUrl), {
70
+ method: "PUT",
71
+ headers: { "content-length": String(file.size) },
72
+ body: Readable.toWeb(createReadStream(filePath)),
73
+ duplex: "half",
74
+ });
75
+ if (response.ok) {
76
+ await response.body?.cancel().catch(() => undefined);
77
+ return;
78
+ }
79
+ if (isTransient(response.status) && attempt < this.retries) {
80
+ uncertainPreviousAttempt = true;
81
+ await response.body?.cancel().catch(() => undefined);
82
+ await this.sleep(100 * 2 ** attempt);
83
+ continue;
84
+ }
85
+ const error = await apiErrorFrom(response);
86
+ // If a response was lost after the server consumed this one-time URL, retrying
87
+ // correctly reports already_used. In that narrow case completion is the proof.
88
+ if (uncertainPreviousAttempt && error.status === 409 && error.code === "already_used") {
89
+ return;
90
+ }
91
+ throw error;
92
+ }
93
+ catch (error) {
94
+ if (error instanceof ApiError)
95
+ throw error;
96
+ if (!(error instanceof TypeError) || attempt === this.retries) {
97
+ const message = error instanceof Error ? error.message : "Network request failed";
98
+ throw new ApiError(`Network request failed: ${oneLine(message)}`);
99
+ }
100
+ uncertainPreviousAttempt = true;
101
+ await this.sleep(100 * 2 ** attempt);
102
+ }
103
+ }
104
+ }
105
+ absoluteUrl(path) {
106
+ return /^https?:\/\//.test(path)
107
+ ? path
108
+ : `${this.url}${path.startsWith("/") ? "" : "/"}${path}`;
109
+ }
110
+ async withRetries(operation) {
111
+ let lastError;
112
+ for (let attempt = 0; attempt <= this.retries; attempt += 1) {
113
+ try {
114
+ return await operation();
115
+ }
116
+ catch (error) {
117
+ if (error instanceof ApiError) {
118
+ throw error;
119
+ }
120
+ lastError = error;
121
+ if (attempt === this.retries) {
122
+ break;
123
+ }
124
+ if (error instanceof TransientResponse) {
125
+ await error.response.body?.cancel().catch(() => undefined);
126
+ }
127
+ else if (!(error instanceof TypeError)) {
128
+ throw error;
129
+ }
130
+ await this.sleep(100 * 2 ** attempt);
131
+ }
132
+ }
133
+ if (lastError instanceof TransientResponse) {
134
+ throw await apiErrorFrom(lastError.response);
135
+ }
136
+ const message = lastError instanceof Error ? lastError.message : "Network request failed";
137
+ throw new ApiError(`Network request failed: ${oneLine(message)}`);
138
+ }
139
+ }
140
+ class TransientResponse extends Error {
141
+ response;
142
+ constructor(response) {
143
+ super(`HTTP ${response.status}`);
144
+ this.response = response;
145
+ }
146
+ }
147
+ function isTransient(status) {
148
+ return status >= 500 && status <= 599;
149
+ }
150
+ async function apiErrorFrom(response) {
151
+ const payload = await readPayload(response);
152
+ const record = isRecord(payload) ? payload : {};
153
+ const data = isRecord(record.data) ? record.data : {};
154
+ const message = stringValue(data.message) ??
155
+ stringValue(record.statusMessage) ??
156
+ stringValue(record.message) ??
157
+ `${response.status} ${response.statusText || "API request failed"}`;
158
+ const code = stringValue(data.error);
159
+ return new ApiError(oneLine(message), response.status, code);
160
+ }
161
+ async function readPayload(response) {
162
+ const text = await response.text();
163
+ if (!text)
164
+ return null;
165
+ try {
166
+ return JSON.parse(text);
167
+ }
168
+ catch {
169
+ return text;
170
+ }
171
+ }
172
+ function isRecord(value) {
173
+ return typeof value === "object" && value !== null && !Array.isArray(value);
174
+ }
175
+ function stringValue(value) {
176
+ return typeof value === "string" && value ? value : undefined;
177
+ }
178
+ function oneLine(value) {
179
+ return value.replace(/\s+/g, " ").trim();
180
+ }
@@ -0,0 +1,12 @@
1
+ export declare class CliError extends Error {
2
+ readonly exitCode: 1 | 2;
3
+ constructor(message: string, exitCode: 1 | 2);
4
+ }
5
+ export declare class UsageError extends CliError {
6
+ constructor(message: string);
7
+ }
8
+ export declare class ApiError extends CliError {
9
+ readonly status?: number | undefined;
10
+ readonly code?: string | undefined;
11
+ constructor(message: string, status?: number | undefined, code?: string | undefined);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ export class CliError extends Error {
2
+ exitCode;
3
+ constructor(message, exitCode) {
4
+ super(message);
5
+ this.exitCode = exitCode;
6
+ this.name = new.target.name;
7
+ }
8
+ }
9
+ export class UsageError extends CliError {
10
+ constructor(message) {
11
+ super(message, 2);
12
+ }
13
+ }
14
+ export class ApiError extends CliError {
15
+ status;
16
+ code;
17
+ constructor(message, status, code) {
18
+ super(message, 1);
19
+ this.status = status;
20
+ this.code = code;
21
+ }
22
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { parseCliArgs } from "./args.js";
4
+ import { ApiClient } from "./client.js";
5
+ import { CliError } from "./errors.js";
6
+ import { executeCommand } from "./operations.js";
7
+ import { resolveRuntime } from "./runtime.js";
8
+ // Read from the manifest rather than a second copy of the number here: the
9
+ // published version is set by the release tag, and a hard-coded constant is
10
+ // guaranteed to start lying the first time one is cut. `../package.json` from
11
+ // both `src/` and `dist/` is the package root, and npm always ships it.
12
+ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
13
+ const HELP = `Usage: dit <command> [options]
14
+
15
+ Commands:
16
+ upload --project <slug|id> --review <ref|id> --file <path> [--file <path> ...]
17
+ feedback --review <ref|id> [--status open|addressed|resolved|all] [--json]
18
+ reply --thread <id> --message <text>
19
+ address --thread <id> [--message <text>]
20
+ revision --artifact <id> --file <path> [--json]
21
+ markdown --review <ref|id>
22
+
23
+ Global options:
24
+ --url <url> API base URL (or DIT_URL)
25
+ --token <token> Agent token (or DIT_TOKEN)
26
+ -h, --help Show help
27
+ -v, --version Show version
28
+
29
+ Upload options:
30
+ --title <text> --commit <sha> --branch <name> --route <path>
31
+ --viewport <WIDTHxHEIGHT[@SCALE]> --scenario <name> --json
32
+ `;
33
+ async function main() {
34
+ const args = parseCliArgs(process.argv.slice(2));
35
+ if (args.command === "help") {
36
+ process.stdout.write(HELP);
37
+ return;
38
+ }
39
+ if (args.command === "version") {
40
+ process.stdout.write(`${VERSION}\n`);
41
+ return;
42
+ }
43
+ const runtime = resolveRuntime(args, process.env);
44
+ const client = new ApiClient(runtime);
45
+ await executeCommand(args, client, {
46
+ out: (value) => process.stdout.write(value),
47
+ warn: (value) => process.stderr.write(`warning: ${value}\n`),
48
+ });
49
+ }
50
+ main().catch((error) => {
51
+ if (process.env.DIT_DEBUG === "1" && error instanceof Error && error.stack) {
52
+ process.stderr.write(`${error.stack}\n`);
53
+ }
54
+ else {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ process.stderr.write(`error: ${message.replace(/\s+/g, " ").trim()}\n`);
57
+ }
58
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
59
+ });
@@ -0,0 +1,25 @@
1
+ export type SupportedMime = "image/png" | "image/jpeg" | "image/webp" | "image/gif" | "video/mp4" | "video/webm";
2
+ export type Dimensions = {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ export type PreparedMedia = {
7
+ path: string;
8
+ name: string;
9
+ kind: "image" | "video";
10
+ mime: SupportedMime;
11
+ bytes: number;
12
+ width?: number;
13
+ height?: number;
14
+ durationMs?: number;
15
+ poster?: {
16
+ path: string;
17
+ mime: "image/jpeg";
18
+ bytes: number;
19
+ };
20
+ cleanup: () => Promise<void>;
21
+ };
22
+ export declare function sniffMediaType(buffer: Uint8Array): SupportedMime | undefined;
23
+ export declare function sniffImageDimensions(buffer: Uint8Array, mime: SupportedMime): Dimensions | undefined;
24
+ export declare function readMediaHeader(path: string, maxBytes?: number): Promise<Buffer>;
25
+ export declare function prepareMedia(path: string, warn: (message: string) => void): Promise<PreparedMedia>;
package/dist/media.js ADDED
@@ -0,0 +1,238 @@
1
+ import { execFile } from "node:child_process";
2
+ import { basename, join } from "node:path";
3
+ import { open, mkdtemp, rm, stat } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { promisify } from "node:util";
6
+ import { CliError } from "./errors.js";
7
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
8
+ const MP4_BRANDS = new Set([
9
+ "isom",
10
+ "iso2",
11
+ "iso4",
12
+ "iso5",
13
+ "iso6",
14
+ "mp41",
15
+ "mp42",
16
+ "avc1",
17
+ "M4V ",
18
+ "dash",
19
+ ]);
20
+ export function sniffMediaType(buffer) {
21
+ const bytes = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength);
22
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_SIGNATURE))
23
+ return "image/png";
24
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
25
+ return "image/jpeg";
26
+ }
27
+ if (bytes.length >= 6 &&
28
+ (bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a")) {
29
+ return "image/gif";
30
+ }
31
+ if (bytes.length >= 12 &&
32
+ bytes.toString("ascii", 0, 4) === "RIFF" &&
33
+ bytes.toString("ascii", 8, 12) === "WEBP") {
34
+ return "image/webp";
35
+ }
36
+ if (bytes.length >= 12 && bytes.toString("ascii", 4, 8) === "ftyp") {
37
+ const brand = bytes.toString("ascii", 8, 12);
38
+ if (MP4_BRANDS.has(brand) || brand.startsWith("mp4"))
39
+ return "video/mp4";
40
+ }
41
+ if (bytes.length >= 4 &&
42
+ bytes[0] === 0x1a &&
43
+ bytes[1] === 0x45 &&
44
+ bytes[2] === 0xdf &&
45
+ bytes[3] === 0xa3) {
46
+ return "video/webm";
47
+ }
48
+ return undefined;
49
+ }
50
+ export function sniffImageDimensions(buffer, mime) {
51
+ const bytes = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength);
52
+ if (mime === "image/png" && bytes.length >= 24) {
53
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
54
+ }
55
+ if (mime === "image/gif" && bytes.length >= 10) {
56
+ return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
57
+ }
58
+ if (mime === "image/jpeg")
59
+ return jpegDimensions(bytes);
60
+ if (mime === "image/webp")
61
+ return webpDimensions(bytes);
62
+ return undefined;
63
+ }
64
+ export async function readMediaHeader(path, maxBytes = 1024 * 1024) {
65
+ const handle = await open(path, "r");
66
+ try {
67
+ const stats = await handle.stat();
68
+ const length = Math.min(stats.size, maxBytes);
69
+ const buffer = Buffer.alloc(length);
70
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
71
+ return buffer.subarray(0, bytesRead);
72
+ }
73
+ finally {
74
+ await handle.close();
75
+ }
76
+ }
77
+ const execFileAsync = promisify(execFile);
78
+ export async function prepareMedia(path, warn) {
79
+ let file;
80
+ try {
81
+ file = await stat(path);
82
+ }
83
+ catch {
84
+ throw new CliError(`File not found: ${path}`, 1);
85
+ }
86
+ if (!file.isFile())
87
+ throw new CliError(`Not a file: ${path}`, 1);
88
+ if (file.size === 0)
89
+ throw new CliError(`File is empty: ${path}`, 1);
90
+ const header = await readMediaHeader(path);
91
+ const mime = sniffMediaType(header);
92
+ if (!mime)
93
+ throw new CliError(`Unsupported media type: ${path}`, 1);
94
+ const kind = mime.startsWith("image/") ? "image" : "video";
95
+ const common = { path, name: basename(path), kind, mime, bytes: file.size };
96
+ if (kind === "image") {
97
+ const dimensions = sniffImageDimensions(header, mime);
98
+ if (!dimensions)
99
+ throw new CliError(`Could not read image dimensions: ${path}`, 1);
100
+ return { ...common, ...dimensions, cleanup: async () => undefined };
101
+ }
102
+ const video = await probeVideo(path, warn);
103
+ const poster = await createPoster(path, warn);
104
+ return {
105
+ ...common,
106
+ ...video,
107
+ ...(poster ? { poster } : {}),
108
+ cleanup: poster?.cleanup ?? (async () => undefined),
109
+ };
110
+ }
111
+ async function probeVideo(path, warn) {
112
+ try {
113
+ const { stdout } = await execFileAsync("ffprobe", [
114
+ "-v",
115
+ "error",
116
+ "-select_streams",
117
+ "v:0",
118
+ "-show_entries",
119
+ "stream=width,height:format=duration",
120
+ "-of",
121
+ "json",
122
+ path,
123
+ ]);
124
+ const payload = JSON.parse(stdout);
125
+ const stream = payload.streams?.[0];
126
+ const duration = Number(payload.format?.duration);
127
+ return {
128
+ ...(Number.isInteger(stream?.width) && stream.width > 0 ? { width: stream.width } : {}),
129
+ ...(Number.isInteger(stream?.height) && stream.height > 0
130
+ ? { height: stream.height }
131
+ : {}),
132
+ ...(Number.isFinite(duration) && duration >= 0
133
+ ? { durationMs: Math.round(duration * 1000) }
134
+ : {}),
135
+ };
136
+ }
137
+ catch (error) {
138
+ warn(toolWarning("ffprobe", "video duration and dimensions skipped", error));
139
+ return {};
140
+ }
141
+ }
142
+ async function createPoster(path, warn) {
143
+ const directory = await mkdtemp(join(tmpdir(), "dit-poster-"));
144
+ const posterPath = join(directory, "poster.jpg");
145
+ try {
146
+ await execFileAsync("ffmpeg", [
147
+ "-v",
148
+ "error",
149
+ "-y",
150
+ "-i",
151
+ path,
152
+ "-frames:v",
153
+ "1",
154
+ "-q:v",
155
+ "3",
156
+ posterPath,
157
+ ]);
158
+ const poster = await stat(posterPath);
159
+ if (poster.size === 0)
160
+ throw new Error("generated an empty poster");
161
+ return {
162
+ path: posterPath,
163
+ mime: "image/jpeg",
164
+ bytes: poster.size,
165
+ cleanup: () => rm(directory, { recursive: true, force: true }),
166
+ };
167
+ }
168
+ catch (error) {
169
+ await rm(directory, { recursive: true, force: true });
170
+ warn(toolWarning("ffmpeg", "video poster skipped", error));
171
+ return undefined;
172
+ }
173
+ }
174
+ function toolWarning(tool, consequence, error) {
175
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
176
+ if (code === "ENOENT")
177
+ return `${tool} not found; ${consequence}`;
178
+ const detail = error instanceof Error ? error.message.replace(/\s+/g, " ").trim() : "failed";
179
+ return `${tool} failed; ${consequence}: ${detail}`;
180
+ }
181
+ function jpegDimensions(bytes) {
182
+ let offset = 2;
183
+ const startOfFrame = new Set([
184
+ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
185
+ ]);
186
+ while (offset + 3 < bytes.length) {
187
+ if (bytes[offset] !== 0xff) {
188
+ offset += 1;
189
+ continue;
190
+ }
191
+ while (bytes[offset] === 0xff)
192
+ offset += 1;
193
+ const marker = bytes[offset];
194
+ offset += 1;
195
+ if (marker === undefined || marker === 0xd9 || marker === 0xda)
196
+ break;
197
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01)
198
+ continue;
199
+ if (offset + 2 > bytes.length)
200
+ break;
201
+ const length = bytes.readUInt16BE(offset);
202
+ if (length < 2 || offset + length > bytes.length)
203
+ break;
204
+ if (startOfFrame.has(marker) && length >= 7) {
205
+ return { height: bytes.readUInt16BE(offset + 3), width: bytes.readUInt16BE(offset + 5) };
206
+ }
207
+ offset += length;
208
+ }
209
+ return undefined;
210
+ }
211
+ function webpDimensions(bytes) {
212
+ if (bytes.length < 25)
213
+ return undefined;
214
+ const kind = bytes.toString("ascii", 12, 16);
215
+ const data = 20;
216
+ if (kind === "VP8X" && bytes.length >= data + 10) {
217
+ return {
218
+ width: readUInt24LE(bytes, data + 4) + 1,
219
+ height: readUInt24LE(bytes, data + 7) + 1,
220
+ };
221
+ }
222
+ if (kind === "VP8 " &&
223
+ bytes.length >= data + 10 &&
224
+ bytes.subarray(data + 3, data + 6).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
225
+ return {
226
+ width: bytes.readUInt16LE(data + 6) & 0x3fff,
227
+ height: bytes.readUInt16LE(data + 8) & 0x3fff,
228
+ };
229
+ }
230
+ if (kind === "VP8L" && bytes.length >= data + 5 && bytes[data] === 0x2f) {
231
+ const bits = bytes.readUInt32LE(data + 1);
232
+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
233
+ }
234
+ return undefined;
235
+ }
236
+ function readUInt24LE(buffer, offset) {
237
+ return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16);
238
+ }
@@ -0,0 +1,6 @@
1
+ import type { CliArgs } from "./args.js";
2
+ import { ApiClient } from "./client.js";
3
+ import { type Output } from "./output.js";
4
+ export declare function executeCommand(args: Exclude<CliArgs, {
5
+ command: "help" | "version";
6
+ }>, client: ApiClient, output: Output): Promise<void>;
@@ -0,0 +1,144 @@
1
+ import { getCatalog, resolveProject, resolveReview } from "./catalog.js";
2
+ import { ApiError } from "./errors.js";
3
+ import { prepareMedia } from "./media.js";
4
+ import { formatFeedback, printJson } from "./output.js";
5
+ export async function executeCommand(args, client, output) {
6
+ if (args.command === "upload")
7
+ return upload(args, client, output);
8
+ if (args.command === "revision")
9
+ return revision(args, client, output);
10
+ if (args.command === "feedback") {
11
+ const review = resolveReview(await getCatalog(client), args.review);
12
+ const payload = await client.requestJson(`/v1/reviews/${encodeURIComponent(review.id)}/threads?status=${args.status}`);
13
+ output.out(args.json ? printJson(payload) : formatFeedback(payload));
14
+ return;
15
+ }
16
+ if (args.command === "reply") {
17
+ await client.requestJson(`/v1/threads/${encodeURIComponent(args.thread)}/replies`, {
18
+ method: "POST",
19
+ body: { body: args.message },
20
+ });
21
+ output.out(`Replied to ${args.thread}.\n`);
22
+ return;
23
+ }
24
+ if (args.command === "address") {
25
+ await client.requestJson(`/v1/threads/${encodeURIComponent(args.thread)}/address`, {
26
+ method: "POST",
27
+ body: args.message ? { body: args.message } : {},
28
+ });
29
+ output.out(`Addressed ${args.thread}.\n`);
30
+ return;
31
+ }
32
+ const review = resolveReview(await getCatalog(client), args.review);
33
+ output.out(await client.requestText(`/v1/reviews/${encodeURIComponent(review.id)}/markdown?format=text`));
34
+ }
35
+ async function upload(args, client, output) {
36
+ const catalog = await getCatalog(client);
37
+ const project = resolveProject(catalog, args.project);
38
+ let reviewPayload;
39
+ if (args.review.startsWith("rev_")) {
40
+ const review = project.reviews.find(({ id }) => id === args.review);
41
+ if (!review)
42
+ throw new ApiError(`Review not found in project ${project.id}: ${args.review}`, 404, "not_found");
43
+ reviewPayload = { reused: true, review };
44
+ }
45
+ else {
46
+ reviewPayload = await client.requestJson("/v1/reviews", {
47
+ method: "POST",
48
+ idempotent: true,
49
+ body: {
50
+ projectId: project.id,
51
+ title: args.title ?? args.review,
52
+ externalRef: args.review,
53
+ ...(args.branch ? { branch: args.branch } : {}),
54
+ ...(args.commit ? { commit: args.commit } : {}),
55
+ },
56
+ });
57
+ }
58
+ const completions = [];
59
+ for (const file of args.files) {
60
+ const prepared = await prepareMedia(file, output.warn);
61
+ try {
62
+ completions.push(await declareUploadComplete(client, prepared, {
63
+ path: "/v1/artifacts/uploads",
64
+ declaration: {
65
+ reviewId: reviewPayload.review.id,
66
+ kind: prepared.kind,
67
+ title: args.title ?? stripExtension(prepared.name),
68
+ ...(args.route ? { route: args.route } : {}),
69
+ ...(args.viewport ? { viewport: args.viewport } : {}),
70
+ ...(args.commit ? { commit: args.commit } : {}),
71
+ ...(args.scenario ? { scenario: args.scenario } : {}),
72
+ },
73
+ }));
74
+ }
75
+ finally {
76
+ await prepared.cleanup();
77
+ }
78
+ }
79
+ const markdown = await client.requestText(`/v1/reviews/${encodeURIComponent(reviewPayload.review.id)}/markdown?format=text`);
80
+ if (args.json) {
81
+ output.out(printJson({
82
+ review: { ...reviewPayload.review, reused: reviewPayload.reused },
83
+ artifacts: completions,
84
+ markdown,
85
+ }));
86
+ }
87
+ else {
88
+ output.out(`Uploaded ${completions.length} artifact${completions.length === 1 ? "" : "s"}.\nReview: ${reviewPayload.review.url}\n\nMarkdown:\n${ensureNewline(markdown)}`);
89
+ }
90
+ }
91
+ async function revision(args, client, output) {
92
+ const prepared = await prepareMedia(args.file, output.warn);
93
+ try {
94
+ const completion = await declareUploadComplete(client, prepared, {
95
+ path: `/v1/artifacts/${encodeURIComponent(args.artifact)}/revisions`,
96
+ declaration: {},
97
+ });
98
+ if (args.json) {
99
+ output.out(printJson(completion));
100
+ }
101
+ else {
102
+ output.out(`Revision ${completion.revisionIndex} ready.\nReview: ${completion.review.url}\n\nMarkdown:\n${ensureNewline(completion.markdown)}`);
103
+ }
104
+ }
105
+ finally {
106
+ await prepared.cleanup();
107
+ }
108
+ }
109
+ async function declareUploadComplete(client, media, options) {
110
+ const declaration = await client.requestJson(options.path, {
111
+ method: "POST",
112
+ idempotent: true,
113
+ body: {
114
+ ...options.declaration,
115
+ mime: media.mime,
116
+ bytes: media.bytes,
117
+ ...(media.poster ? { poster: { mime: media.poster.mime, bytes: media.poster.bytes } } : {}),
118
+ },
119
+ });
120
+ await client.uploadFile(declaration.uploadUrl, media.path);
121
+ if (media.poster && declaration.poster) {
122
+ await client.uploadFile(declaration.poster.uploadUrl, media.poster.path);
123
+ }
124
+ return client.requestJson(`/v1/artifacts/${encodeURIComponent(declaration.artifactId)}/complete`, {
125
+ method: "POST",
126
+ idempotent: true,
127
+ body: {
128
+ revisionId: declaration.revisionId,
129
+ ...(media.width ? { width: media.width } : {}),
130
+ ...(media.height ? { height: media.height } : {}),
131
+ ...(media.durationMs === undefined ? {} : { durationMs: media.durationMs }),
132
+ ...(media.poster && declaration.poster
133
+ ? { posterUploadToken: declaration.poster.uploadToken }
134
+ : {}),
135
+ },
136
+ });
137
+ }
138
+ function stripExtension(name) {
139
+ const stripped = name.replace(/\.[^.]+$/, "");
140
+ return stripped || name;
141
+ }
142
+ function ensureNewline(value) {
143
+ return value.endsWith("\n") ? value : `${value}\n`;
144
+ }
@@ -0,0 +1,10 @@
1
+ export type Output = {
2
+ out: (value: string) => void;
3
+ warn: (value: string) => void;
4
+ };
5
+ export declare function printJson(value: unknown): string;
6
+ export declare function formatFeedback(payload: {
7
+ count: number;
8
+ status: string;
9
+ threads: Array<Record<string, unknown>>;
10
+ }): string;
package/dist/output.js ADDED
@@ -0,0 +1,41 @@
1
+ export function printJson(value) {
2
+ return `${JSON.stringify(value, null, 2)}\n`;
3
+ }
4
+ export function formatFeedback(payload) {
5
+ if (payload.count === 0)
6
+ return `No ${payload.status} feedback.\n`;
7
+ const rows = payload.threads.map((thread) => {
8
+ const artifact = record(thread.artifact);
9
+ return [
10
+ text(thread.threadId),
11
+ text(thread.status),
12
+ text(artifact.title) || "(review)",
13
+ text(thread.revision) || "-",
14
+ text(thread.kind),
15
+ oneLine(text(thread.comment)),
16
+ ];
17
+ });
18
+ const headings = ["THREAD", "STATUS", "ARTIFACT", "REV", "KIND", "COMMENT"];
19
+ const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => row[column].length)));
20
+ const line = (row) => row
21
+ .map((value, column) => value.padEnd(widths[column]))
22
+ .join(" ")
23
+ .trimEnd();
24
+ return `${line(headings)}\n${rows.map(line).join("\n")}\n`;
25
+ }
26
+ function record(value) {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value)
28
+ ? value
29
+ : {};
30
+ }
31
+ function text(value) {
32
+ if (typeof value === "string")
33
+ return value;
34
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
35
+ return String(value);
36
+ }
37
+ return "";
38
+ }
39
+ function oneLine(value) {
40
+ return value.replace(/\s+/g, " ").trim();
41
+ }
@@ -0,0 +1,9 @@
1
+ import type { CliArgs } from "./args.js";
2
+ type RuntimeArgs = Exclude<CliArgs, {
3
+ command: "help" | "version";
4
+ }>;
5
+ export declare function resolveRuntime(args: RuntimeArgs, environment: NodeJS.ProcessEnv): {
6
+ url: string;
7
+ token: string;
8
+ };
9
+ export {};
@@ -0,0 +1,20 @@
1
+ import { UsageError } from "./errors.js";
2
+ export function resolveRuntime(args, environment) {
3
+ const url = args.url ?? environment.DIT_URL;
4
+ const token = args.token ?? environment.DIT_TOKEN;
5
+ if (!url)
6
+ throw new UsageError("Missing base URL: pass --url or set DIT_URL");
7
+ if (!token)
8
+ throw new UsageError("Missing auth token: pass --token or set DIT_TOKEN");
9
+ let parsed;
10
+ try {
11
+ parsed = new URL(url);
12
+ }
13
+ catch {
14
+ throw new UsageError("DIT_URL/--url must be an absolute HTTP(S) URL");
15
+ }
16
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
17
+ throw new UsageError("DIT_URL/--url must be an absolute HTTP(S) URL");
18
+ }
19
+ return { url: parsed.toString().replace(/\/+$/, ""), token };
20
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@pixelhop/dit",
3
+ "version": "0.1.0",
4
+ "description": "Did It Though? CLI for coding agents — upload PR screenshots and videos, pull structured feedback back",
5
+ "keywords": [
6
+ "agents",
7
+ "cli",
8
+ "code-review",
9
+ "diditthough",
10
+ "screenshots",
11
+ "visual-review"
12
+ ],
13
+ "homepage": "https://diditthough.app",
14
+ "bugs": {
15
+ "url": "https://github.com/pixelhop/diditthough/issues"
16
+ },
17
+ "license": "ISC",
18
+ "author": "Pixelhop <hello@pixelhop.io> (https://pixelhop.io)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/pixelhop/diditthough.git",
22
+ "directory": "packages/cli"
23
+ },
24
+ "bin": {
25
+ "dit": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "type": "module",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.18.0",
36
+ "typescript": "^5.9.2",
37
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
38
+ },
39
+ "engines": {
40
+ "node": ">=22"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/index.js', 0o755)\"",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc -p tsconfig.json"
46
+ }
47
+ }