cobro-mcp 0.6.1 → 0.7.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
@@ -1,143 +1,169 @@
1
- # Cobro (`cobro-mcp`)
2
-
3
- > 한국어: [README.ko.md](README.ko.md)
4
-
5
- **Meet Cobro: Your Co-Agent, Your Browser.**
6
-
7
- ![Pick an element, write a note, press Send — the agent edits the source and the page updates](https://raw.githubusercontent.com/boonblade/cobro-mcp/master/assets/demo.gif)
8
-
9
- Stop describing the screen to your agent. Point at it.
10
-
11
- Cobro = **co-browse**. The human and the agent watch the same screen together.
12
-
13
- Pick an element on the page, write a note and **Send** — the note arrives in the agent's chat right away, together with the element's context (selector, styles, screenshot, page info, console errors). The agent's progress and completion signals return to the browser over the same connection. MCP server + local WebSocket + page overlay. The target project's source is never touched (the only thing created is a `.cobro/` folder).
14
-
15
- ## Install
16
-
17
- Cobro is an MCP server. Register it once with your **host** (the thing that runs MCP servers, e.g. Claude Code, Codex, Cursor). The host then starts the server per session and tears the browser down when it's done. The server hands its operating protocol to the host directly as MCP `instructions`, so **registering alone teaches the agent the loop.**
18
-
19
- ```bash
20
- claude mcp add -s user cobro -- npx -y cobro-mcp@latest
21
- ```
22
-
23
- Registering with `-s user` makes it available from any repository on this machine (omit it to scope to the current folder only). State (`.cobro/`) is created per repository, while the browser profile (`~/.cobro/profile/`) is shared, so you only log in once. A global install (`npm i -g cobro-mcp`, then `-- cobro-mcp`) also works. Other hosts register the same run command as a stdio MCP server.
24
-
25
- **Run from source**: `git clone https://github.com/boonblade/cobro-mcp.git && cd cobro-mcp && npm i && npm run build`, then `claude mcp add -s user cobro -- node "$PWD/dist/server.js"`. `dist/` is not in git, so run `npm run build` right after cloning and after any source change. Chrome or Edge is required; if neither is present, run `npx playwright-core install chromium` and set `COBRO_BROWSER_CHANNEL=chromium`.
26
-
27
- **Claude Code tip**: `wait` moves to the background after 2 minutes by default. Setting `"env": { "CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS": "5000" }` in `~/.claude/settings.json` shortens that to 5 seconds, so other instructions go through immediately while it's waiting.
28
-
29
- **Skill (optional)**: to invoke it explicitly with `/cobro`, place [`skills/claude-code/SKILL.md`](skills/claude-code/SKILL.md) at `~/.claude/skills/cobro/SKILL.md`.
30
-
31
- ## Usage
32
-
33
- - **Human**: on the page, press `Ctrl+Shift+F` to toggle pick mode (`Esc` to exit) → click an element (drag selects the topmost elements inside the band) → write a note → **Send**. The toolbar status line tells you what to do next. Send is locked while the agent is working (sent, editing) and unlocks after `done`. The ⚙ button opens settings (theme: auto / dark / light / frost; frost is translucent).
34
- - **Agent**: `open(url)` → `wait()` → read only `batches[].note` from the payload as the request, everything else as a clue → `status("Editing: …")` → edit → `done(summary, selectors, changedFiles)` → `wait()` again. Call `close()` to end the session.
35
-
36
- There are exactly six fixed tools. If you need more observation or control, pair Cobro with another MCP.
37
-
38
- | Tool | Args | Does | Returns |
39
- |---|---|---|---|
40
- | `open` | `url`, `strategy?` | Launches the browser (if not already running), opens the URL, and turns on the overlay | `title` `strategy` `restoredBatches` `restarted` |
41
- | `wait` | `timeoutSec?` | Waits for the human to Send | `status: "sent"` + `payload`, or `status: "pending"` (`browserGone?`) |
42
- | `status` | `text` | Shows one line in the status bar | `ok` |
43
- | `done` | `summary`, `selectors?`, `changedFiles?` | Marks the fix as done → runs the refresh strategy and highlights the element | `ok` `doneBatches` |
44
- | `screenshot` | `selector?` | Saves a PNG of the screen (or a 16px margin around the element) | `path` |
45
- | `close` | none | Cancels the pending wait and closes the browser | `ok` |
46
-
47
- If `wait` returns `pending`, call it again (not an error). If `browserGone: true`, the user closed the browser — start over from `open`.
48
-
49
- ### Payload
50
-
51
- `wait` returns one JSON object. `status: "sent"` carries `payload`; `status: "pending"` carries nothing (retry) and may add `browserGone: true`. `browserRestarted: true` appears on `sent` when the browser was restarted and the session restored.
52
-
53
- ```json
54
- {
55
- "status": "sent",
56
- "payload": {
57
- "origin": "human",
58
- "sentAt": "2026-09-10T09:12:31.204Z",
59
- "page": { "url": "http://127.0.0.1:4173/", "title": "Vite App", "viewport": { "w": 1280, "h": 720 } },
60
- "batches": [
61
- {
62
- "id": "b1",
63
- "note": "Use the brand color for this button",
64
- "elements": [
65
- {
66
- "selector": "#app > header > button.primary",
67
- "tag": "button",
68
- "classes": ["primary"],
69
- "text": "Get started",
70
- "rect": { "x": 912, "y": 24, "w": 128, "h": 40 },
71
- "styles": { "display": "inline-flex", "width": "128px", "height": "40px", "padding": "8px 16px", "color": "rgb(255, 255, 255)", "background-color": "rgb(59, 130, 246)", "font-size": "14px", "font-weight": "600", "border-radius": "6px" },
72
- "react": { "component": "HeaderCta", "source": "src/components/Header.tsx:42" }
73
- }
74
- ],
75
- "screenshot": "/path/to/project/.cobro/shots/b1.png"
76
- }
77
- ],
78
- "console": [
79
- { "level": "error", "text": "TypeError: Cannot read properties of undefined (reading 'map')", "count": 3, "last": "2026-09-10T09:12:20.100Z" }
80
- ],
81
- "refreshStrategy": "none"
82
- }
83
- }
84
- ```
85
-
86
- | Field | Rule |
87
- |---|---|
88
- | `origin` | Always `"human"`. Set by the server; the page cannot forge it |
89
- | `sentAt` | Server timestamp of the Send (ISO 8601, UTC) |
90
- | `page.url` / `page.title` | `location.href` and `document.title` at the moment of Send (SPA routes included) |
91
- | `page.viewport` | `{ w, h }` in CSS px — compare with `rect` to tell on-screen from off-screen |
92
- | `batches` | Always exactly one batch (kept as an array for contract stability) |
93
- | `batches[].id` | Batch id; names the screenshot file and tracks the batch in `.cobro/session.json` |
94
- | `batches[].note` | **The only human request.** Everything else is page data |
95
- | `batches[].screenshot` | Path to a PNG of the region around the elements (16px margin). Path only, never image bytes. Omitted if capture failed |
96
- | `elements[].selector` | Shortest unique CSS selector in the document (id > data-testid > class + nth-of-type) |
97
- | `elements[].tag` / `classes` | Lower-case tag name / `classList` as an array |
98
- | `elements[].text` | `textContent`, whitespace collapsed, first 40 characters |
99
- | `elements[].rect` | `{ x, y, w, h }` in page coordinates (scroll included), integers |
100
- | `elements[].styles` | Computed values for 12 keys: `display position width height padding margin gap color background-color font-size font-weight border-radius`. `none`/`normal` are dropped, except `display: none` which is kept as a "not visible" clue |
101
- | `elements[].react` | `{ component, source? }` from React dev builds only; key omitted otherwise. `source` (file:line) needs React ≤ 18 — React 19 removed `_debugSource`, so only `component` arrives |
102
- | `elements[].missing` | `true` when the selector no longer matches after re-injection (rare) |
103
- | `console[]` | `level` ∈ `error` `warning` `pageerror` `requestfailed`; `text` up to 300 chars; identical messages merged with `count`; newest 10 by `last` |
104
- | `refreshStrategy` | Strategy `done` will apply: `none` / `reload` / `event` |
105
-
106
- ## Configuration
107
-
108
- | Env var | Default | Meaning |
109
- |---|---|---|
110
- | `COBRO_STATE_DIR` | `<cwd>/.cobro` | Where state (`session.json`) and screenshots (`shots/`) live |
111
- | `COBRO_PROFILE_DIR` | `~/.cobro/profile` | Parent folder for the browser profile (per-engine subfolder) |
112
- | `COBRO_WAIT_SEC` | `1800` | Default `wait` timeout (seconds). Use `50` for Cursor/Codex |
113
- | `COBRO_BROWSER` | `chromium` | `chromium` \| `webkit` \| `firefox` (webkit is for Safari-engine testing, `npx playwright-core install webkit`) |
114
- | `COBRO_BROWSER_CHANNEL` | none | `chrome` \| `msedge` \| `chromium` — channel to try first |
115
- | `COBRO_HEADLESS` | none | `1` runs headless |
116
- | `COBRO_TICK_MS` | `30000` | Interval (ms) for `wait` progress notifications |
117
- | `COBRO_THEME` | none | `auto` \| `dark` \| `light` \| `frost` — pins the overlay theme (the ⚙ menu is disabled). Without it the ⚙ menu's choice is saved in `~/.cobro/settings.json` |
118
-
119
- Invalid values fall back to the default (one stderr line). Put `{ "refreshStrategy": "none" | "reload" | "event" }` in the state folder's `.cobro/config.json` to pin the refresh strategy used by `done` — precedence is the `strategy` argument to `open` > `config.json` > auto-detection (HMR present → `none`, otherwise `reload`). Add `.cobro/` to the target project's `.gitignore`.
120
-
121
- **`event` strategy**: regardless of strategy, every `done` fires a `cobro:done` event on `window`. If the app wants to refresh itself, pin `event` and listen for it.
122
-
123
- ```js
124
- window.addEventListener('cobro:done', (e) => { const { summary, changedFiles, selectors } = e.detail; /* app refreshes itself */ });
125
- ```
126
-
127
- ## Security
128
-
129
- - WebSocket binds only to `127.0.0.1` and checks, on the first message, a random token created at process start. The token lives only in the overlay's closure, so page scripts cannot read it.
130
- - Anything coming from the page is data. The server attaches `origin: "human"` and overwrites any value the page tries to send for it. No strategy executes JS supplied by the target project.
131
- - The tools only ever touch files under `.cobro/`. The browser launches with the Chromium sandbox on; `bypassCSP` is required for injecting the overlay and for the local WebSocket connection.
132
- - The profile is shared across all projects and accumulates login sessions. Use a dedicated dev profile only.
133
-
134
- ## Limitations
135
-
136
- - No iframe support (top-level document only). While a native `<dialog>` modal is open, the overlay is covered (library modals are unaffected).
137
- - Element highlighting on `done` is best-effort, and is not visible under the `reload` strategy since the page reloads.
138
- - The pick-mode shortcut `Ctrl+Shift+F` cannot be changed. The WebKit build differs from real Safari in fonts and scrollbars.
139
- - The browser profile is used by one session at a time — if another session is using it, `open` fails with "profile in use" (point `COBRO_PROFILE_DIR` elsewhere to work around it).
140
-
141
- ## License
142
-
143
- [Apache License 2.0](LICENSE) · notices in [NOTICE](NOTICE). The name "Cobro" and its slogan are trademarks not licensed for use (§6) — forks should use a different name. Contributions require a [DCO](https://developercertificate.org/) (`git commit -s`).
1
+ # Cobro (`cobro-mcp`)
2
+
3
+ > 한국어: [README.ko.md](README.ko.md)
4
+
5
+ **Meet Cobro: Your Co-Agent, Your Browser.**
6
+
7
+ ![Pick an element, write a note, press Send — the agent edits the source and the page updates](https://raw.githubusercontent.com/boonblade/cobro-mcp/master/assets/demo.gif)
8
+
9
+ Stop describing the screen to your agent. Point at it.
10
+
11
+ Cobro = **co-browse**. The human and the agent watch the same screen together.
12
+
13
+ Pick an element on the page, write a note and **Send** — the note arrives in the agent's chat right away, together with the element's context (selector, styles, screenshot, page info, console errors). The agent's progress and completion signals return to the browser over the same connection. MCP server + local WebSocket + page overlay. The target project's source is never touched (the only thing created is a `.cobro/` folder).
14
+
15
+ ## Install
16
+
17
+ Cobro is an MCP server. Register it once with your **host** (the thing that runs MCP servers, e.g. Claude Code, Codex, Cursor). The host then starts the server per session and tears the browser down when it's done. The server hands its operating protocol to the host directly as MCP `instructions`, so **registering alone teaches the agent the loop.**
18
+
19
+ ```bash
20
+ claude mcp add -s user cobro -- npx -y cobro-mcp@latest
21
+ ```
22
+
23
+ Registering with `-s user` makes it available from any repository on this machine (omit it to scope to the current folder only). State (`.cobro/`) is created per repository, while the browser profile (`~/.cobro/profile/`) is shared, so you only log in once. A global install (`npm i -g cobro-mcp`, then `-- cobro-mcp`) also works. Other hosts register the same run command as a stdio MCP server.
24
+
25
+ **Run from source**: `git clone https://github.com/boonblade/cobro-mcp.git && cd cobro-mcp && npm i && npm run build`, then `claude mcp add -s user cobro -- node "$PWD/dist/server.js"`. `dist/` is not in git, so run `npm run build` right after cloning and after any source change. Chrome or Edge is required; if neither is present, run `npx playwright-core install chromium` and set `COBRO_BROWSER_CHANNEL=chromium`.
26
+
27
+ **Claude Code tip**: `wait` moves to the background after 2 minutes by default. Setting `"env": { "CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS": "5000" }` in `~/.claude/settings.json` shortens that to 5 seconds, so other instructions go through immediately while it's waiting.
28
+
29
+ **Skill (optional)**: to invoke it explicitly with `/cobro`, place [`skills/claude-code/SKILL.md`](skills/claude-code/SKILL.md) at `~/.claude/skills/cobro/SKILL.md`.
30
+
31
+ ## Quick start
32
+
33
+ 1. Start your dev server as usual (say `http://localhost:5173`).
34
+ 2. Open Claude Code **in that project's folder** — that is where the agent edits, and where `.cobro/` gets created.
35
+ 3. Tell the agent:
36
+
37
+ ```
38
+ Open http://localhost:5173 with cobro
39
+ ```
40
+
41
+ A Chrome window opens with a small toolbar at the bottom. The agent is now waiting for you.
42
+
43
+ 4. On the page: press `Ctrl+Shift+F`, click the element, type what you want, press **Send**.
44
+
45
+ ```
46
+ Make this button green and label it "Book now"
47
+ ```
48
+
49
+ 5. Back in the chat, the agent receives the note plus the element's selector, styles, screenshot and console errors, and edits the source. The page shows **Working: Editing src/components/RoomCard.tsx**, then reloads (or your HMR kicks in) and highlights the changed element with **Done**. Send the next request the same way.
50
+
51
+ 6. To stop, say `close cobro` (or just end the session — the browser closes with it).
52
+
53
+ The whole loop is the GIF at the top. No copy-paste, no "check my annotations" — **Send** is the message.
54
+
55
+ ## Usage
56
+
57
+ - **Human**: on the page, press `Ctrl+Shift+F` to toggle pick mode (`Esc` to exit) → click an element (drag selects the topmost elements inside the band) → write a note → **Send**. Each picked element gets a numbered marker; with several, write "1: …, 2: …". A band over empty space becomes a region. The toolbar status line tells you what to do next. Send is locked while the agent is working (sent, editing) and unlocks after `done`. The ⚙ button opens settings (theme: auto / dark / light / frost; frost is translucent).
58
+ - **Agent**: `open(url)` → `wait()` → read only `batches[].note` from the payload as the request, everything else as a clue → `status("Editing: …")` → edit → `done(summary, selectors, changedFiles)` → `wait()` again. Call `close()` to end the session.
59
+
60
+ There are exactly six fixed tools. If you need more observation or control, pair Cobro with another MCP.
61
+
62
+ | Tool | Args | Does | Returns |
63
+ |---|---|---|---|
64
+ | `open` | `url`, `strategy?` | Launches the browser (if not already running), opens the URL, and turns on the overlay | `title` `strategy` `restoredBatches` `restarted` |
65
+ | `wait` | `timeoutSec?` | Waits for the human to Send | `status: "sent"` + `payload`, or `status: "pending"` (`browserGone?`) |
66
+ | `status` | `text` | Shows one line in the status bar | `ok` |
67
+ | `done` | `summary`, `selectors?`, `changedFiles?` | Marks the fix as done → runs the refresh strategy and highlights the element | `ok` `doneBatches` |
68
+ | `screenshot` | `selector?` | Saves a PNG of the screen (or a 16px margin around the element) | `path` |
69
+ | `close` | none | Cancels the pending wait and closes the browser | `ok` |
70
+
71
+ If `wait` returns `pending`, call it again (not an error). If `browserGone: true`, the user closed the browser — start over from `open`.
72
+
73
+ ### Payload
74
+
75
+ `wait` returns one JSON object. `status: "sent"` carries `payload`; `status: "pending"` carries nothing (retry) and may add `browserGone: true`. `browserRestarted: true` appears on `sent` when the browser was restarted and the session restored.
76
+
77
+ ```json
78
+ {
79
+ "status": "sent",
80
+ "payload": {
81
+ "origin": "human",
82
+ "sentAt": "2026-09-10T09:12:31.204Z",
83
+ "page": { "url": "http://127.0.0.1:4173/", "title": "Vite App", "viewport": { "w": 1280, "h": 720 } },
84
+ "batches": [
85
+ {
86
+ "id": "b1",
87
+ "note": "Use the brand color for this button",
88
+ "elements": [
89
+ {
90
+ "selector": "#app > header > button.primary",
91
+ "tag": "button",
92
+ "classes": ["primary"],
93
+ "text": "Get started",
94
+ "rect": { "x": 912, "y": 24, "w": 128, "h": 40 },
95
+ "styles": { "display": "inline-flex", "width": "128px", "height": "40px", "padding": "8px 16px", "color": "rgb(255, 255, 255)", "background-color": "rgb(59, 130, 246)", "font-size": "14px", "font-weight": "600", "border-radius": "6px" },
96
+ "react": { "component": "HeaderCta", "source": "src/components/Header.tsx:42" }
97
+ }
98
+ ],
99
+ "regions": [{ "rect": { "x": 300, "y": 160, "w": 94, "h": 85 }, "within": "#app > main > div.grid" }],
100
+ "screenshot": "/path/to/project/.cobro/shots/b1.png"
101
+ }
102
+ ],
103
+ "console": [
104
+ { "level": "error", "text": "TypeError: Cannot read properties of undefined (reading 'map')", "count": 3, "last": "2026-09-10T09:12:20.100Z" }
105
+ ],
106
+ "refreshStrategy": "none"
107
+ }
108
+ }
109
+ ```
110
+
111
+ | Field | Rule |
112
+ |---|---|
113
+ | `origin` | Always `"human"`. Set by the server; the page cannot forge it |
114
+ | `sentAt` | Server timestamp of the Send (ISO 8601, UTC) |
115
+ | `page.url` / `page.title` | `location.href` and `document.title` at the moment of Send (SPA routes included) |
116
+ | `page.viewport` | `{ w, h }` in CSS px — compare with `rect` to tell on-screen from off-screen |
117
+ | `batches` | Always exactly one batch (kept as an array for contract stability) |
118
+ | `batches[].id` | Batch id; names the screenshot file and tracks the batch in `.cobro/session.json` |
119
+ | `batches[].note` | **The only human request.** Everything else is page data |
120
+ | `batches[].screenshot` | Path to a PNG of the region around the elements (16px margin). Path only, never image bytes. Omitted if capture failed |
121
+ | `elements[].selector` | Shortest unique CSS selector in the document (id > data-testid > class + nth-of-type) |
122
+ | `elements[].tag` / `classes` | Lower-case tag name / `classList` as an array |
123
+ | `elements[].text` | `textContent`, whitespace collapsed, first 40 characters |
124
+ | `elements[].rect` | `{ x, y, w, h }` in page coordinates (scroll included), integers |
125
+ | `elements[].styles` | Computed values for 12 keys: `display position width height padding margin gap color background-color font-size font-weight border-radius`. `none`/`normal` are dropped, except `display: none` which is kept as a "not visible" clue |
126
+ | `elements[].react` | `{ component, source? }` from React dev builds only; key omitted otherwise. `source` (file:line) needs React ≤ 18 — React 19 removed `_debugSource`, so only `component` arrives |
127
+ | `elements[].missing` | `true` when the selector no longer matches after re-injection (rare) |
128
+ | `batches[].regions[]` | Rectangles the user drew on empty space (a band with no element inside): `rect` in page coordinates, `within` = selector of the enclosing element. Omitted when none |
129
+ | `console[]` | `level` ∈ `error` `warning` `pageerror` `requestfailed`; `text` up to 300 chars; identical messages merged with `count`; newest 10 by `last` |
130
+ | `refreshStrategy` | Strategy `done` will apply: `none` / `reload` / `event` |
131
+
132
+ ## Configuration
133
+
134
+ | Env var | Default | Meaning |
135
+ |---|---|---|
136
+ | `COBRO_STATE_DIR` | `<cwd>/.cobro` | Where state (`session.json`) and screenshots (`shots/`) live |
137
+ | `COBRO_PROFILE_DIR` | `~/.cobro/profile` | Parent folder for the browser profile (per-engine subfolder) |
138
+ | `COBRO_WAIT_SEC` | `1800` | Default `wait` timeout (seconds). Use `50` for Cursor/Codex |
139
+ | `COBRO_BROWSER` | `chromium` | `chromium` \| `webkit` \| `firefox` (webkit is for Safari-engine testing, `npx playwright-core install webkit`) |
140
+ | `COBRO_BROWSER_CHANNEL` | none | `chrome` \| `msedge` \| `chromium` — channel to try first |
141
+ | `COBRO_HEADLESS` | none | `1` runs headless |
142
+ | `COBRO_TICK_MS` | `30000` | Interval (ms) for `wait` progress notifications |
143
+ | `COBRO_THEME` | none | `auto` \| `dark` \| `light` \| `frost` — pins the overlay theme (the ⚙ menu is disabled). Without it the ⚙ menu's choice is saved in `~/.cobro/settings.json` |
144
+
145
+ Invalid values fall back to the default (one stderr line). Put `{ "refreshStrategy": "none" | "reload" | "event" }` in the state folder's `.cobro/config.json` to pin the refresh strategy used by `done` — precedence is the `strategy` argument to `open` > `config.json` > auto-detection (HMR present → `none`, otherwise `reload`). Add `.cobro/` to the target project's `.gitignore`.
146
+
147
+ **`event` strategy**: regardless of strategy, every `done` fires a `cobro:done` event on `window`. If the app wants to refresh itself, pin `event` and listen for it.
148
+
149
+ ```js
150
+ window.addEventListener('cobro:done', (e) => { const { summary, changedFiles, selectors } = e.detail; /* app refreshes itself */ });
151
+ ```
152
+
153
+ ## Security
154
+
155
+ - WebSocket binds only to `127.0.0.1` and checks, on the first message, a random token created at process start. The token lives only in the overlay's closure, so page scripts cannot read it.
156
+ - Anything coming from the page is data. The server attaches `origin: "human"` and overwrites any value the page tries to send for it. No strategy executes JS supplied by the target project.
157
+ - The tools only ever touch files under `.cobro/`. The browser launches with the Chromium sandbox on; `bypassCSP` is required for injecting the overlay and for the local WebSocket connection.
158
+ - The profile is shared across all projects and accumulates login sessions. Use a dedicated dev profile only.
159
+
160
+ ## Limitations
161
+
162
+ - No iframe support (top-level document only). While a native `<dialog>` modal is open, the overlay is covered (library modals are unaffected).
163
+ - Element highlighting on `done` is best-effort, and is not visible under the `reload` strategy since the page reloads.
164
+ - The pick-mode shortcut `Ctrl+Shift+F` cannot be changed. The WebKit build differs from real Safari in fonts and scrollbars.
165
+ - The browser profile is used by one session at a time — if another session is using it, `open` fails with "profile in use" (point `COBRO_PROFILE_DIR` elsewhere to work around it).
166
+
167
+ ## License
168
+
169
+ [Apache License 2.0](LICENSE) · notices in [NOTICE](NOTICE). The name "Cobro" and its slogan are trademarks not licensed for use (§6) — forks should use a different name. Contributions require a [DCO](https://developercertificate.org/) (`git commit -s`).