@pixelhop/dit 0.2.0 → 0.4.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 CHANGED
@@ -8,28 +8,39 @@ Node 22+. No runtime dependencies.
8
8
 
9
9
  ```bash
10
10
  npm install -g @pixelhop/dit
11
- dit login --token dit_…
11
+ dit login
12
+ dit project create --name "Marketing site"
12
13
  dit upload --project marketing-site --review pr-1234 --file shot.png
13
14
  ```
14
15
 
15
- `--url` defaults to `https://diditthough.app` and `login` remembers the token, so neither
16
- has to be repeated.
16
+ `dit login` on its own prints a link. Open it, approve, and the CLI has its own key —
17
+ nothing to copy, and no account needed beforehand. `--url` defaults to
18
+ `https://diditthough.app` and the token is remembered, so neither is repeated.
17
19
 
18
20
  ## Signing in
19
21
 
20
22
  Sign in once; after that every command is just the command.
21
23
 
22
24
  ```bash
23
- dit login --token dit_…
25
+ dit login
24
26
  ```
25
27
 
26
- That checks the token against the API before storing it a token saved without checking
27
- turns one clear failure here into a puzzling one on the next upload and writes it to
28
- `~/.config/dit/config.json` with `0600` permissions. `$XDG_CONFIG_HOME` is respected, and
29
- `DIT_CONFIG` overrides the path outright. `dit logout` removes it.
28
+ With no arguments this runs a browser approval. The CLI prints a link and a short code and
29
+ waits; a person opens the link, signs in or signs up, and clicks approve. The CLI is then
30
+ issued its own agent token scoped to that person's workspace. Nothing is ever copied
31
+ between windows, and the person's session is discarded immediately it is a courier for
32
+ the approval, not the credential the agent keeps.
33
+
34
+ You can still pass a token directly, which is what CI does:
35
+
36
+ ```bash
37
+ dit login --token dit_…
38
+ ```
30
39
 
31
- The token is scoped to one project and one workspace, and a human has to create the
32
- project and mint the token first: agents cannot create projects.
40
+ Either way the token is checked against the API before being stored a token saved
41
+ without checking turns one clear failure here into a puzzling one on the next upload — and
42
+ written to `~/.config/dit/config.json` with `0600` permissions. `$XDG_CONFIG_HOME` is
43
+ respected, and `DIT_CONFIG` overrides the path outright. `dit logout` removes it.
33
44
 
34
45
  ### Where each value comes from
35
46
 
@@ -51,13 +62,47 @@ of one-line errors.
51
62
  ### `dit login` and `dit logout`
52
63
 
53
64
  ```bash
54
- dit login --token dit_… # the hosted service
65
+ dit login # browser approval
66
+ dit login --token dit_… # a token you already have
55
67
  dit login --token dit_… --url http://localhost:3000 # a local server
56
68
  dit logout
57
69
  ```
58
70
 
59
71
  `login` also accepts the token from `DIT_TOKEN`, which keeps it out of your shell history.
60
72
 
73
+ ### Uploading with no account at all
74
+
75
+ If nothing is signed in, `dit upload` does not fail. It creates a temporary workspace that
76
+ nobody owns yet, uploads into that, and prints a claim link alongside the usual Markdown:
77
+
78
+ ```
79
+ This workspace has not been claimed. Everything uploaded here is deleted in 5 days unless
80
+ somebody claims it.
81
+ Claim it: https://diditthough.app/claim/…
82
+ ```
83
+
84
+ Pass that link on. Whoever follows it and signs in becomes the owner, and the workspace
85
+ moves to the Free plan with everything in it intact — including evidence that has already
86
+ stopped displaying, because the visible window is derived from the current plan rather than
87
+ frozen at upload time. Unclaimed workspaces are small on purpose: one project, three
88
+ reviews, media visible for five days and recoverable for seven more.
89
+
90
+ Deployments can switch this off, in which case `dit upload` asks you to run `dit login`
91
+ instead.
92
+
93
+ ### `dit project create`
94
+
95
+ A project is where reviews live, one per repository or site. An agent can make its own,
96
+ provided its token is workspace-scoped rather than pinned to a single project — a token
97
+ tied to one project cannot create another, because widening your own reach is not
98
+ something a credential should be able to do.
99
+
100
+ ```bash
101
+ dit project create --name "Marketing site"
102
+ ```
103
+
104
+ It prints the slug to pass to `--project` afterwards.
105
+
61
106
  ### `dit upload`
62
107
 
63
108
  Uploads one or more files to a review, creating the review if it does not exist yet.
@@ -71,11 +116,15 @@ dit upload --project marketing-site --review pr-1234 \
71
116
 
72
117
  `--review` takes either an existing `rev_…` id or your own reference — a PR number, a run
73
118
  id, anything stable. A reference that has been seen before reuses that review, so a job
74
- that runs twice on the same PR adds revisions instead of a second review. `--project`
119
+ that runs twice on the same PR adds screens to the existing review. To update an existing
120
+ screen, use `dit revision --artifact <id>` instead. `--project`
75
121
  takes the project slug or its `prj_…` id.
76
122
 
77
123
  Per-capture context, all optional: `--route /checkout`, `--viewport 390x844@3`,
78
- `--scenario "logged out"`, `--title`. Pass `--json` for the machine-readable form; the
124
+ `--scenario "logged out"`, `--title`, `--description`, and `--group`. Give each screen a
125
+ clear title and describe what the reviewer should check. These flags apply to every file
126
+ in a command; use separate commands with the same `--round` key when titles, descriptions
127
+ or viewports differ. Pass `--json` for the machine-readable form; the
79
128
  default prints the review URL and the Markdown block to paste into a PR.
80
129
 
81
130
  Images are sniffed by magic bytes (PNG, JPEG, WebP, GIF) and their dimensions read from
@@ -83,6 +132,42 @@ the file, so a mislabelled extension does not matter. For MP4 and WebM the CLI s
83
132
  to `ffprobe` for duration and dimensions, and to `ffmpeg` for a poster frame — both are
84
133
  optional. Without them the upload still succeeds, with a warning, minus the poster.
85
134
 
135
+ ### Design alternatives
136
+
137
+ Use the same `--group` name within a review for alternatives to the same design. Upload
138
+ options separately so each has its own title, description, artifact ID and revision history:
139
+
140
+ ```bash
141
+ dit upload --project marketing-site --review pr-1234 --file checkout-a.png \
142
+ --title "Checkout — compact" --description "Shorter form with an inline order summary" \
143
+ --group "Checkout layout" --round checkout-options --summary "Two checkout layouts to compare"
144
+ dit upload --project marketing-site --review pr-1234 --file checkout-b.png \
145
+ --title "Checkout — split" --description "Form and order summary side by side" \
146
+ --group "Checkout layout" --round checkout-options
147
+ ```
148
+
149
+ Use `--json` to get artifact IDs and keep them for later `revision` commands. A variation group
150
+ connects alternatives; a round groups a batch of changes. The human chooses the preferred
151
+ option.
152
+
153
+ ### Review rounds
154
+
155
+ Each upload or revision command creates one round; an upload includes all of its files. Use the same
156
+ `--round` key on upload and revision commands to group one batch of changes:
157
+
158
+ ```sh
159
+ dit revision --artifact art_123 --file overview.png --round polish-2 --summary "Tightened spacing and simplified the mobile header"
160
+ dit upload --project my-app --review rev_123 --file mobile.png --round polish-2
161
+ ```
162
+
163
+ Use a new key for the next batch. Historical round keys cannot be extended after a
164
+ new round starts. The first declaration supplies the round's optional summary.
165
+ Review rounds track evidence; screen titles, choices and feedback retain their
166
+ current state. Older uploads are shown as an Earlier uploads baseline.
167
+ Rounds let the reviewer step through the evidence timeline. Their Updated filter and
168
+ Mark as seen state are personal: agents must not clear them or verify the review.
169
+ After a feedback pass, reply, mark the relevant threads addressed, and return the review URL.
170
+
86
171
  ### `dit feedback`
87
172
 
88
173
  ```bash
package/dist/args.d.ts CHANGED
@@ -16,7 +16,11 @@ export type CliArgs = ({
16
16
  review: string;
17
17
  files: string[];
18
18
  } & RuntimeFlags & JsonFlag & {
19
+ round?: string;
20
+ summary?: string;
19
21
  title?: string;
22
+ description?: string;
23
+ group?: string;
20
24
  commit?: string;
21
25
  branch?: string;
22
26
  route?: string;
@@ -38,10 +42,15 @@ export type CliArgs = ({
38
42
  command: "revision";
39
43
  artifact: string;
40
44
  file: string;
45
+ round?: string;
46
+ summary?: string;
41
47
  } & RuntimeFlags & JsonFlag) | ({
42
48
  command: "markdown";
43
49
  review: string;
44
50
  } & RuntimeFlags) | ({
51
+ command: "project:create";
52
+ name: string;
53
+ } & RuntimeFlags & JsonFlag) | ({
45
54
  command: "login";
46
55
  } & RuntimeFlags) | ({
47
56
  command: "logout";
package/dist/args.js CHANGED
@@ -7,11 +7,15 @@ const runtimeOptions = {
7
7
  };
8
8
  const commandOptions = {
9
9
  upload: {
10
+ round: { type: "string" },
11
+ summary: { type: "string" },
10
12
  ...runtimeOptions,
11
13
  project: { type: "string" },
12
14
  review: { type: "string" },
13
15
  file: { type: "string", multiple: true },
14
16
  title: { type: "string" },
17
+ description: { type: "string" },
18
+ group: { type: "string" },
15
19
  commit: { type: "string" },
16
20
  branch: { type: "string" },
17
21
  route: { type: "string" },
@@ -36,6 +40,8 @@ const commandOptions = {
36
40
  message: { type: "string" },
37
41
  },
38
42
  revision: {
43
+ round: { type: "string" },
44
+ summary: { type: "string" },
39
45
  ...runtimeOptions,
40
46
  artifact: { type: "string" },
41
47
  file: { type: "string" },
@@ -45,6 +51,11 @@ const commandOptions = {
45
51
  ...runtimeOptions,
46
52
  review: { type: "string" },
47
53
  },
54
+ "project:create": {
55
+ ...runtimeOptions,
56
+ name: { type: "string" },
57
+ json: { type: "boolean" },
58
+ },
48
59
  login: runtimeOptions,
49
60
  logout: {
50
61
  url: { type: "string" },
@@ -52,9 +63,13 @@ const commandOptions = {
52
63
  },
53
64
  };
54
65
  export function parseCliArgs(argv) {
55
- const command = argv[0];
66
+ // `project create` is the only two-word command. Joining it into the same
67
+ // `project:create` key the rest of the table uses keeps one lookup rather than
68
+ // a special case threaded through everything below.
69
+ const argv_ = argv[0] === "project" && argv[1] ? [`project:${argv[1]}`, ...argv.slice(2)] : argv;
70
+ const command = argv_[0];
56
71
  if (!command || command === "help" || command === "--help" || command === "-h") {
57
- return { command: "help", ...(argv[1] ? { topic: argv[1] } : {}) };
72
+ return { command: "help", ...(argv_[1] ? { topic: argv_[1] } : {}) };
58
73
  }
59
74
  if (command === "--version" || command === "-v" || command === "version") {
60
75
  return { command: "version" };
@@ -66,7 +81,7 @@ export function parseCliArgs(argv) {
66
81
  let values;
67
82
  try {
68
83
  ({ values } = parseArgs({
69
- args: argv.slice(1),
84
+ args: argv_.slice(1),
70
85
  options: commandOptions[name],
71
86
  strict: true,
72
87
  allowPositionals: false,
@@ -89,7 +104,11 @@ export function parseCliArgs(argv) {
89
104
  project: required(values, "project", name),
90
105
  review: required(values, "review", name),
91
106
  files,
107
+ ...optional(values, "round"),
108
+ ...optional(values, "summary"),
92
109
  ...optional(values, "title"),
110
+ ...optional(values, "description"),
111
+ ...optional(values, "group"),
93
112
  ...optional(values, "commit"),
94
113
  ...optional(values, "branch"),
95
114
  ...optional(values, "route"),
@@ -128,6 +147,14 @@ export function parseCliArgs(argv) {
128
147
  ...runtime,
129
148
  };
130
149
  }
150
+ if (name === "project:create") {
151
+ return {
152
+ command: name,
153
+ name: required(values, "name", "project create"),
154
+ ...(values.json ? { json: true } : {}),
155
+ ...runtime,
156
+ };
157
+ }
131
158
  if (name === "login") {
132
159
  return { command: name, ...runtime };
133
160
  }
@@ -139,6 +166,8 @@ export function parseCliArgs(argv) {
139
166
  command: name,
140
167
  artifact: required(values, "artifact", name),
141
168
  file: required(values, "file", name),
169
+ ...optional(values, "round"),
170
+ ...optional(values, "summary"),
142
171
  ...(values.json ? { json: true } : {}),
143
172
  ...runtime,
144
173
  };
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { CliArgs } from "./args.js";
2
2
  import { ApiClient, type ApiClientOptions } from "./client.js";
3
+ import { type DeviceLoginDeps } from "./device.js";
3
4
  import type { Output } from "./output.js";
4
5
  type CreateClient = (options: ApiClientOptions) => ApiClient;
5
6
  export declare function login(args: Extract<CliArgs, {
6
7
  command: "login";
7
- }>, environment: NodeJS.ProcessEnv, output: Output, createClient?: CreateClient): Promise<void>;
8
+ }>, environment: NodeJS.ProcessEnv, output: Output, createClient?: CreateClient, device?: DeviceLoginDeps): Promise<void>;
8
9
  export declare function logout(args: Extract<CliArgs, {
9
10
  command: "logout";
10
11
  }>, environment: NodeJS.ProcessEnv, output: Output): Promise<void>;
package/dist/auth.js CHANGED
@@ -1,16 +1,19 @@
1
1
  import { getCatalog } from "./catalog.js";
2
2
  import { ApiClient } from "./client.js";
3
3
  import { clearStoredToken, configPath, writeStoredToken } from "./config.js";
4
- import { ApiError, UsageError } from "./errors.js";
4
+ import { deviceLogin } from "./device.js";
5
+ import { ApiError } from "./errors.js";
5
6
  import { resolveUrl } from "./runtime.js";
6
- export async function login(args, environment, output, createClient = (options) => new ApiClient(options)) {
7
+ export async function login(args, environment, output, createClient = (options) => new ApiClient(options), device = {}) {
7
8
  const url = resolveUrl(args.url, environment);
8
- const token = args.token ?? environment.DIT_TOKEN;
9
- if (!token) {
10
- throw new UsageError("login requires --token (or DIT_TOKEN)");
11
- }
9
+ // No token given? Ask a human to approve one in a browser instead. This is the
10
+ // whole point: an agent can get itself signed in without anyone copying a
11
+ // secret between two windows.
12
+ const token = args.token ?? environment.DIT_TOKEN ?? (await deviceLogin(url, output, device));
12
13
  // Check the token before writing it. Storing a dead token turns one clear
13
- // failure here into a confusing one on the next upload.
14
+ // failure here into a confusing one on the next upload. A token that has just
15
+ // come back from the device flow is checked too — it costs one request, and it
16
+ // means "signed in" is never printed over something that does not work.
14
17
  try {
15
18
  await getCatalog(createClient({ url, token }));
16
19
  }
@@ -0,0 +1,25 @@
1
+ import type { Output } from "./output.js";
2
+ export type DeviceLoginDeps = {
3
+ fetch?: typeof fetch;
4
+ sleep?: (milliseconds: number) => Promise<void>;
5
+ now?: () => number;
6
+ };
7
+ export declare function deviceLogin(url: string, output: Output, deps?: DeviceLoginDeps): Promise<string>;
8
+ export type Bootstrapped = {
9
+ token: string;
10
+ project: {
11
+ slug: string;
12
+ };
13
+ claimUrl: string;
14
+ warning: string;
15
+ };
16
+ /**
17
+ * Make somewhere to upload to for an agent with no credentials at all.
18
+ *
19
+ * This is what lets an agent show its human the product rather than describe it:
20
+ * it uploads the evidence first and hands over a link, and the human claims the
21
+ * result if they want to keep it. The warning and the claim URL are returned by
22
+ * the server rather than composed here, so the retention number a person reads
23
+ * always comes from the plan catalogue and cannot drift.
24
+ */
25
+ export declare function bootstrapUnclaimed(url: string, projectName: string, doFetch?: typeof fetch): Promise<Bootstrapped>;
package/dist/device.js ADDED
@@ -0,0 +1,160 @@
1
+ import { ApiError, CliError } from "./errors.js";
2
+ /**
3
+ * Signing in without a token to paste.
4
+ *
5
+ * The CLI asks the server for a pair of codes, shows the person a URL and a
6
+ * short code, and polls until they have approved it in a browser. What comes
7
+ * back is a *session* token, which is not what an agent should hold: it belongs
8
+ * to the person, expires on their schedule, and carries their full authority.
9
+ * So the last step trades it immediately for a workspace-scoped `dit_` agent
10
+ * token and forgets the session. The session is a courier.
11
+ *
12
+ * The shape of the exchange is OAuth 2.0 device authorization (RFC 8628), which
13
+ * is the same flow `gh auth login` uses, so the polling error codes below are
14
+ * that specification's rather than this product's.
15
+ */
16
+ const CLIENT_ID = "dit-cli";
17
+ const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
18
+ /**
19
+ * Every capability an agent needs to do the whole loop.
20
+ *
21
+ * `project:create` is in here because `dit project create` is the very next
22
+ * thing an agent runs after signing in, and a token without it gets a 403 on
23
+ * the step the documentation tells it to take.
24
+ */
25
+ const AGENT_CAPABILITIES = [
26
+ "artifact:write",
27
+ "artifact:read",
28
+ "feedback:read",
29
+ "thread:reply",
30
+ "project:create",
31
+ ];
32
+ export async function deviceLogin(url, output, deps = {}) {
33
+ const doFetch = deps.fetch ?? globalThis.fetch;
34
+ const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
35
+ const now = deps.now ?? (() => Date.now());
36
+ const start = await requestCode(url, doFetch);
37
+ const link = start.verification_uri_complete ?? start.verification_uri;
38
+ output.out(`To finish signing in, open this and approve:\n\n ${link}\n\n` +
39
+ `If it asks for a code, it is: ${start.user_code}\n\nWaiting…\n`);
40
+ const accessToken = await poll(url, start, { doFetch, sleep, now });
41
+ return exchangeForAgentToken(url, accessToken, doFetch);
42
+ }
43
+ async function requestCode(url, doFetch) {
44
+ const response = await doFetch(`${url}/api/auth/device/code`, {
45
+ method: "POST",
46
+ headers: { "content-type": "application/json" },
47
+ body: JSON.stringify({ client_id: CLIENT_ID }),
48
+ });
49
+ if (!response.ok) {
50
+ throw new ApiError(`Could not start sign-in at ${url} (HTTP ${response.status})`, response.status);
51
+ }
52
+ return (await response.json());
53
+ }
54
+ async function poll(url, start, runtime) {
55
+ // The server states how often it wants to be asked and how long the code is
56
+ // good for. Both are honoured rather than guessed: polling faster than asked
57
+ // earns a `slow_down`, and polling past the expiry just annoys the server.
58
+ let intervalMs = (start.interval ?? 5) * 1000;
59
+ const deadline = runtime.now() + start.expires_in * 1000;
60
+ while (runtime.now() < deadline) {
61
+ await runtime.sleep(intervalMs);
62
+ const response = await runtime.doFetch(`${url}/api/auth/device/token`, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify({
66
+ grant_type: GRANT_TYPE,
67
+ device_code: start.device_code,
68
+ client_id: CLIENT_ID,
69
+ }),
70
+ });
71
+ const payload = (await response.json().catch(() => ({})));
72
+ if (payload.access_token)
73
+ return payload.access_token;
74
+ switch (payload.error) {
75
+ case "authorization_pending":
76
+ break;
77
+ case "slow_down":
78
+ intervalMs += 5000;
79
+ break;
80
+ case "access_denied":
81
+ throw new CliError("Sign-in was denied in the browser.", 1);
82
+ case "expired_token":
83
+ throw new CliError("The sign-in code expired. Run `dit login` again.", 1);
84
+ default:
85
+ throw new ApiError(payload.error_description ?? payload.error ?? "Sign-in failed", response.status, payload.error);
86
+ }
87
+ }
88
+ throw new CliError("Timed out waiting for approval. Run `dit login` again.", 1);
89
+ }
90
+ /**
91
+ * Swap the person's session for an agent token belonging to their workspace.
92
+ *
93
+ * Deliberately workspace-scoped rather than tied to one project, because an
94
+ * agent that cannot create a project would still need a human to make one.
95
+ */
96
+ async function exchangeForAgentToken(url, accessToken, doFetch) {
97
+ // Which workspace is asked for explicitly. The server refuses to guess when
98
+ // somebody belongs to more than one, and belonging to two is ordinary — it is
99
+ // what happens the moment they claim a workspace an agent made for them, so
100
+ // leaving this out breaks sign-in for exactly the people this feature creates.
101
+ const workspaceId = await currentWorkspaceId(url, accessToken, doFetch);
102
+ const response = await doFetch(`${url}/api/tokens`, {
103
+ method: "POST",
104
+ headers: {
105
+ "content-type": "application/json",
106
+ authorization: `Bearer ${accessToken}`,
107
+ },
108
+ body: JSON.stringify({
109
+ name: agentTokenName(),
110
+ capabilities: [...AGENT_CAPABILITIES],
111
+ ...(workspaceId ? { workspaceId } : {}),
112
+ }),
113
+ });
114
+ if (!response.ok) {
115
+ const detail = (await response.json().catch(() => ({})));
116
+ throw new ApiError(detail.message ?? `Could not create an agent token (HTTP ${response.status})`, response.status);
117
+ }
118
+ const created = (await response.json());
119
+ if (!created.token) {
120
+ throw new ApiError("The server did not return an agent token", response.status);
121
+ }
122
+ return created.token;
123
+ }
124
+ /** The workspace `/api/me` reports for this person, if it will say. */
125
+ async function currentWorkspaceId(url, accessToken, doFetch) {
126
+ const response = await doFetch(`${url}/api/me`, {
127
+ headers: { authorization: `Bearer ${accessToken}` },
128
+ });
129
+ if (!response.ok)
130
+ return undefined;
131
+ const payload = (await response.json().catch(() => ({})));
132
+ return payload.workspace?.id;
133
+ }
134
+ function agentTokenName() {
135
+ const host = process.env.HOSTNAME?.trim();
136
+ return host ? `dit CLI on ${host}` : "dit CLI";
137
+ }
138
+ /**
139
+ * Make somewhere to upload to for an agent with no credentials at all.
140
+ *
141
+ * This is what lets an agent show its human the product rather than describe it:
142
+ * it uploads the evidence first and hands over a link, and the human claims the
143
+ * result if they want to keep it. The warning and the claim URL are returned by
144
+ * the server rather than composed here, so the retention number a person reads
145
+ * always comes from the plan catalogue and cannot drift.
146
+ */
147
+ export async function bootstrapUnclaimed(url, projectName, doFetch = globalThis.fetch) {
148
+ const response = await doFetch(`${url}/v1/bootstrap`, {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify({ project: projectName }),
152
+ });
153
+ const payload = (await response.json().catch(() => ({})));
154
+ if (!response.ok || !payload.token) {
155
+ throw new ApiError(payload.data?.message ??
156
+ payload.message ??
157
+ `Could not create a workspace at ${url} (HTTP ${response.status})`, response.status);
158
+ }
159
+ return payload;
160
+ }
package/dist/index.js CHANGED
@@ -3,10 +3,11 @@ import { createRequire } from "node:module";
3
3
  import { parseCliArgs } from "./args.js";
4
4
  import { login, logout } from "./auth.js";
5
5
  import { ApiClient } from "./client.js";
6
- import { DEFAULT_URL } from "./config.js";
6
+ import { DEFAULT_URL, writeStoredToken } from "./config.js";
7
+ import { bootstrapUnclaimed } from "./device.js";
7
8
  import { CliError } from "./errors.js";
8
9
  import { executeCommand } from "./operations.js";
9
- import { resolveRuntime } from "./runtime.js";
10
+ import { findToken, resolveRuntime, resolveUrl } from "./runtime.js";
10
11
  // Read from the manifest rather than a second copy of the number here: the
11
12
  // published version is set by the release tag, and a hard-coded constant is
12
13
  // guaranteed to start lying the first time one is cut. `../package.json` from
@@ -15,14 +16,15 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
15
16
  const HELP = `Usage: dit <command> [options]
16
17
 
17
18
  Commands:
18
- login --token <token> [--url <url>] Save the token for later commands
19
+ login [--token <token>] [--url <url>] Sign in; opens a browser approval
19
20
  logout [--url <url>]
20
21
  upload --project <slug|id> --review <ref|id> --file <path> [--file <path> ...]
21
22
  feedback --review <ref|id> [--status open|addressed|resolved|all] [--json]
22
23
  reply --thread <id> --message <text>
23
24
  address --thread <id> [--message <text>]
24
- revision --artifact <id> --file <path> [--json]
25
+ revision --artifact <id> --file <path> [--round <key>] [--summary <text>] [--json]
25
26
  markdown --review <ref|id>
27
+ project create --name <name> [--json]
26
28
 
27
29
  Global options:
28
30
  --url <url> API base URL (default ${DEFAULT_URL}, or DIT_URL)
@@ -33,6 +35,19 @@ Global options:
33
35
  Upload options:
34
36
  --title <text> --commit <sha> --branch <name> --route <path>
35
37
  --viewport <WIDTHxHEIGHT[@SCALE]> --scenario <name> --json
38
+ --description <text> --group <name> Context and alternatives for the same design
39
+ --round <key> --summary <text> Group uploads into a review round with a change summary
40
+
41
+ Review workflow:
42
+ Give screens a clear --title and --description. Upload flags apply to every file.
43
+ Alternatives: upload each option with a distinct title and the same --group name.
44
+ Existing screen: use revision --artifact <id> to retain its history and notes.
45
+ One feedback pass: share a --round key across upload and revision commands.
46
+ Use a new key for the next pass; older rounds cannot be extended once a new one starts.
47
+ The first command sets --summary. Without --round, each command creates a round.
48
+ Reuse --review for the same PR; upload adds screens, revision updates a screen.
49
+ Read feedback --json, fix, upload revisions, reply, then address the threads.
50
+ Humans choose preferred designs, mark updates as seen, and verify reviews.
36
51
  `;
37
52
  async function main() {
38
53
  const args = parseCliArgs(process.argv.slice(2));
@@ -52,6 +67,27 @@ async function main() {
52
67
  return login(args, process.env, output);
53
68
  if (args.command === "logout")
54
69
  return logout(args, process.env, output);
70
+ // An upload with no credentials anywhere does not fail. It creates a temporary
71
+ // workspace nobody owns yet, uploads into that, and hands back a link the
72
+ // human can claim — which is the whole point: an agent can show this product
73
+ // working on its human's own change before that human has an account.
74
+ if (args.command === "upload") {
75
+ const url = resolveUrl(args.url, process.env);
76
+ const existing = await findToken(args, url, process.env);
77
+ if (!existing) {
78
+ const created = await bootstrapUnclaimed(url, args.project);
79
+ // Saved, not held for the one command. Without this the agent cannot come
80
+ // back for the notes: `dit feedback` would find no token and bootstrap a
81
+ // second workspace, so the review it asked about would be one nobody had
82
+ // looked at. Saving it also means a later claim simply makes the token's
83
+ // workspace somebody's, and the agent carries on unaware.
84
+ await writeStoredToken(url, created.token, process.env);
85
+ output.out(`${created.warning}\nClaim it: ${created.claimUrl}\n\n`);
86
+ await executeCommand({ ...args, project: created.project.slug }, new ApiClient({ url, token: created.token }), output);
87
+ output.out(`\nThis review is not claimed yet. Claim it: ${created.claimUrl}\n`);
88
+ return;
89
+ }
90
+ }
55
91
  const runtime = await resolveRuntime(args, process.env);
56
92
  await executeCommand(args, new ApiClient(runtime), output);
57
93
  }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { getCatalog, resolveProject, resolveReview } from "./catalog.js";
2
3
  import { ApiError } from "./errors.js";
3
4
  import { prepareMedia } from "./media.js";
@@ -29,6 +30,13 @@ export async function executeCommand(args, client, output) {
29
30
  output.out(`Addressed ${args.thread}.\n`);
30
31
  return;
31
32
  }
33
+ if (args.command === "project:create") {
34
+ const created = await client.requestJson("/v1/projects", { method: "POST", idempotent: true, body: { name: args.name } });
35
+ output.out(args.json
36
+ ? printJson(created)
37
+ : `Created project ${created.project.name}.\nUse --project ${created.project.slug} when uploading.\n`);
38
+ return;
39
+ }
32
40
  const review = resolveReview(await getCatalog(client), args.review);
33
41
  output.out(await client.requestText(`/v1/reviews/${encodeURIComponent(review.id)}/markdown?format=text`));
34
42
  }
@@ -55,6 +63,7 @@ async function upload(args, client, output) {
55
63
  },
56
64
  });
57
65
  }
66
+ const roundKey = args.round ?? randomUUID();
58
67
  const completions = [];
59
68
  for (const file of args.files) {
60
69
  const prepared = await prepareMedia(file, output.warn);
@@ -62,9 +71,13 @@ async function upload(args, client, output) {
62
71
  completions.push(await declareUploadComplete(client, prepared, {
63
72
  path: "/v1/artifacts/uploads",
64
73
  declaration: {
74
+ roundKey,
75
+ ...(args.summary ? { roundSummary: args.summary } : {}),
65
76
  reviewId: reviewPayload.review.id,
66
77
  kind: prepared.kind,
67
78
  title: args.title ?? stripExtension(prepared.name),
79
+ ...(args.description ? { description: args.description } : {}),
80
+ ...(args.group ? { variationGroup: args.group } : {}),
68
81
  ...(args.route ? { route: args.route } : {}),
69
82
  ...(args.viewport ? { viewport: args.viewport } : {}),
70
83
  ...(args.commit ? { commit: args.commit } : {}),
@@ -93,7 +106,10 @@ async function revision(args, client, output) {
93
106
  try {
94
107
  const completion = await declareUploadComplete(client, prepared, {
95
108
  path: `/v1/artifacts/${encodeURIComponent(args.artifact)}/revisions`,
96
- declaration: {},
109
+ declaration: {
110
+ roundKey: args.round ?? randomUUID(),
111
+ ...(args.summary ? { roundSummary: args.summary } : {}),
112
+ },
97
113
  });
98
114
  if (args.json) {
99
115
  output.out(printJson(completion));
package/dist/runtime.d.ts CHANGED
@@ -9,3 +9,8 @@ export declare function resolveRuntime(args: ApiCommandArgs, environment: NodeJS
9
9
  url: string;
10
10
  token: string;
11
11
  }>;
12
+ /** The same resolution, without insisting there be an answer. */
13
+ export declare function findToken(args: {
14
+ token?: string;
15
+ url?: string;
16
+ }, url: string, environment: NodeJS.ProcessEnv): Promise<string | undefined>;
package/dist/runtime.js CHANGED
@@ -21,9 +21,13 @@ export function resolveUrl(url, environment) {
21
21
  */
22
22
  export async function resolveRuntime(args, environment) {
23
23
  const url = resolveUrl(args.url, environment);
24
- const token = args.token ?? environment.DIT_TOKEN ?? (await readStoredToken(url, environment));
24
+ const token = await findToken(args, url, environment);
25
25
  if (!token) {
26
- throw new UsageError(`No agent token for ${url}: run \`dit login --token dit_…\`, pass --token, or set DIT_TOKEN`);
26
+ throw new UsageError(`No agent token for ${url}: run \`dit login\`, pass --token, or set DIT_TOKEN`);
27
27
  }
28
28
  return { url, token };
29
29
  }
30
+ /** The same resolution, without insisting there be an answer. */
31
+ export async function findToken(args, url, environment) {
32
+ return args.token ?? environment.DIT_TOKEN ?? (await readStoredToken(url, environment));
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelhop/dit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Did It Though? CLI for coding agents — upload PR screenshots and videos, pull structured feedback back",
5
5
  "keywords": [
6
6
  "agents",