Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

CLI install puts diffinery on PATH right away, and diffinery setup does sign-in, git and gh in one go (#253)

* The CLI installer puts diffinery on PATH so it works right away, and `diffinery setup` does sign-in, git and gh in one command
test-bot authored 1 parent 7bde4a5commit fa6eca63b2d282248562a79a30d69ac9fe784c41Browse files
9 files changed+297−58
Files (9)
modifiedapps/cli/install/install.sh+73−34View file
@@ -4,7 +4,8 @@
44 # curl -fsSL https://diffinery.com/install | bash
55 #
66 # The CLI goes in ~/.diffinery (set DIFFINERY_INSTALL to change that), and ~/.diffinery/bin is
7-# added to PATH in your shell's startup file (DIFFINERY_NO_MODIFY_PATH=1 skips that). It runs on
7+# added to PATH in your shells' startup files, with a link in ~/.local/bin or ~/bin when one of
8+# those is on PATH already so it works at once (DIFFINERY_NO_MODIFY_PATH=1 skips both). It runs on
89 # Node.js 22 or later: the one on your PATH when it's new enough, otherwise a private copy of the
910 # Node.js LTS from nodejs.org, checked against its published SHA-256, in ~/.diffinery/node.
1011 # Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/.
@@ -102,42 +103,80 @@
102103 mv -f "$tmp/diffinery" "$bin/diffinery"
103104 version=$("$bin/diffinery" --version) || die "the CLI was installed in $(tilde "$root") but doesn't start; run $bin/diffinery --version to see why"
104105
105-# 4. PATH.
106-note=''
107-case ":$PATH:" in
108- *":$bin:"*) ;;
109- *)
110- if [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then
111- line="export PATH=\"$bin:\$PATH\""
112- case $(basename "${SHELL:-sh}") in
113- zsh) rc=${ZDOTDIR:-$HOME}/.zshrc ;;
114- bash) if [ "$os" = darwin ]; then rc=$HOME/.bash_profile; else rc=$HOME/.bashrc; fi ;;
115- fish)
116- rc=$HOME/.config/fish/conf.d/diffinery.fish
117- line="fish_add_path \"$bin\""
118- ;;
119- *) rc=$HOME/.profile ;;
120- esac
121- if grep -qsF "$bin" "$rc"; then
122- did="$(tilde "$rc") already adds $(tilde "$bin") to PATH."
123- else
124- mkdir -p "$(dirname "$rc")"
125- printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc"
126- did="Added $(tilde "$bin") to PATH in $(tilde "$rc")."
106+# 4. PATH: in startup files for new terminals, plus a link in a directory already on PATH (such as
107+# ~/.local/bin) so the command works right away, in this terminal and in agents' shells too.
108+added='' kept='' ready='' linked=''
109+# As written in startup files: $HOME/.diffinery/bin rather than the expanded path, when it's in $HOME.
110+case $bin in "$HOME"/*) rc_bin='$HOME'${bin#"$HOME"} ;; *) rc_bin=$bin ;; esac
111+on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac; return 1; }
112+# Adds the line for this kind of file, once: sh syntax for bash, zsh and sh, fish's own for fish.
113+add_to() {
114+ local rc=$1 line
115+ case $rc in
116+ *.fish) line="fish_add_path \"$rc_bin\"" ;;
117+ *) line="case \":\$PATH:\" in *\":$rc_bin:\"*) ;; *) export PATH=\"$rc_bin:\$PATH\" ;; esac" ;;
118+ esac
119+ if grep -qsF -e "$bin" -e "$rc_bin" "$rc"; then kept=${kept:+$kept, }$(tilde "$rc"); return; fi
120+ mkdir -p "$(dirname "$rc")"
121+ printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc"
122+ added=${added:+$added, }$(tilde "$rc")
123+}
124+if on_path "$bin"; then
125+ ready=1
126+elif [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then
127+ # The startup files of the login shell, created if missing, and of any other shell already set
128+ # up here. Bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile in a login
129+ # shell (every macOS terminal), so the line goes in the one it reads, never a new one that hides it.
130+ shell=$(basename "${SHELL:-sh}")
131+ zrc=${ZDOTDIR:-$HOME}/.zshrc
132+ bash_login=''
133+ for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
134+ if [ -f "$f" ]; then bash_login=$f; break; fi
135+ done
136+ rcs=()
137+ case $shell in
138+ zsh) rcs+=("$zrc") ;;
139+ bash) rcs+=("$HOME/.bashrc" "${bash_login:-$([ "$os" = darwin ] && echo "$HOME/.bash_profile" || echo "$HOME/.profile")}") ;;
140+ fish) rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") ;;
141+ *) rcs+=("$HOME/.profile") ;;
142+ esac
143+ [ "$shell" = zsh ] || [ ! -f "$zrc" ] || rcs+=("$zrc")
144+ [ "$shell" = bash ] || [ ! -f "$HOME/.bashrc" ] || rcs+=("$HOME/.bashrc")
145+ [ "$shell" = fish ] || [ ! -d "$HOME/.config/fish" ] || rcs+=("$HOME/.config/fish/conf.d/diffinery.fish")
146+ for rc in "${rcs[@]}"; do add_to "$rc"; done
147+ # A link from a personal bin directory that's already on PATH, unless something else lives there.
148+ for dir in "$HOME/.local/bin" "$HOME/bin"; do
149+ if on_path "$dir" && [ -d "$dir" ] && [ -w "$dir" ]; then
150+ if [ "$(readlink "$dir/diffinery" 2>/dev/null || true)" = "$bin/diffinery" ] || { [ ! -e "$dir/diffinery" ] && [ ! -L "$dir/diffinery" ]; }; then
151+ ln -sfn "$bin/diffinery" "$dir/diffinery"
152+ linked=$dir/diffinery ready=1
127153 fi
128- note="$did Open a new terminal, or run: ${bold}export PATH=\"$bin:\$PATH\"${reset}"
129- else
130- note="Add $(tilde "$bin") to your PATH to run diffinery."
154+ break
131155 fi
132- ;;
133-esac
156+ done
157+ # In a workflow job, later steps get it on PATH too.
158+ if [ -n "${GITHUB_PATH:-}" ] && [ -w "${GITHUB_PATH}" ]; then echo "$bin" >>"$GITHUB_PATH"; fi
159+fi
134160
135161 say "${green}✓${reset} ${version} installed to $(tilde "$bin/diffinery")${node:+ ${dim}(with its own Node.js)${reset}}"
136-[ -z "$note" ] || say "$note"
162+if [ -n "$added" ]; then
163+ say "Added $(tilde "$bin") to PATH in $added."
164+elif [ -n "$kept" ]; then
165+ say "New terminals already get $(tilde "$bin") on PATH from $kept."
166+fi
167+[ -z "$linked" ] || say "Linked it from $(tilde "$linked"), so diffinery works in this terminal now."
168+if [ -z "$ready" ]; then
169+ if [ -n "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then
170+ say "Add $(tilde "$bin") to your PATH to run diffinery."
171+ else
172+ say "Open a new terminal, or run this to use diffinery in this one:"
173+ say " ${bold}export PATH=\"$rc_bin:\$PATH\"${reset}"
174+ fi
175+fi
137176 other=$(command -v diffinery 2>/dev/null || true)
138-if [ -n "$other" ] && [ "$other" != "$bin/diffinery" ]; then say "${dim}Another diffinery at $(tilde "$other") comes first on your PATH; remove it to use this one.${reset}"; fi
139-login="diffinery login"
140-[ "$server" = https://diffinery.com ] || login="diffinery login --url $server"
177+if [ -n "$other" ] && [ "$other" != "$bin/diffinery" ] && [ "$(readlink "$other" 2>/dev/null || true)" != "$bin/diffinery" ]; then say "${dim}Another diffinery at $(tilde "$other") comes first on your PATH; remove it to use this one.${reset}"; fi
178+setup="diffinery setup"
179+[ "$server" = https://diffinery.com ] || setup="diffinery setup --url $server"
141180 say ""
142-say "Next, sign in through your browser:"
143-say " ${bold}$login${reset}"
181+say "Next, sign in through your browser and set up git (and gh, if you have it):"
182+say " ${bold}$setup${reset}"
modifiedapps/cli/src/commands/auth.ts+57−3View file
@@ -1,5 +1,5 @@
1-import { Api } from "../api.js";
2-import { str, type Command, type Group } from "../cli.js";
1+import { Api, ApiError } from "../api.js";
2+import { Ctx, str, type Command, type Group } from "../cli.js";
33 import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js";
44 import { spawn } from "node:child_process";
55 import { gitCapture } from "../git.js";
@@ -204,5 +204,59 @@
204204 },
205205 };
206206
207-export const authCommands: Command[] = [login, logout, whoami, mcpConfig];
207+/** The signed-in account, or null when there is no token or the server turns it down. */
208+async function signedInAs(url: string, token: string | null): Promise<User | null> {
209+ if (!token) return null;
210+ try {
211+ return await new Api(url, token).get<User>("/user");
212+ } catch (e) {
213+ if (e instanceof ApiError && e.status === 401) return null;
214+ throw e;
215+ }
216+}
217+
218+const setup: Command = {
219+ name: "setup",
220+ summary: "Set up this computer in one go: sign in, then git and gh",
221+ description: `Signs in through your browser (or with a code, on a machine without one) unless the CLI is
222+signed in already, then runs \`diffinery auth setup-git\` so git clone, fetch and push use the
223+sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Safe to run again.`,
224+ options: {
225+ url: login.options!.url!,
226+ scope: login.options!.scope!,
227+ web: login.options!.web!,
228+ device: login.options!.device!,
229+ },
230+ maxArgs: 0,
231+ async run(ctx, o) {
232+ const cfg = await ctx.config();
233+ const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url;
234+ const me = url === cfg.url ? await signedInAs(url, cfg.token) : null;
235+ if (me) ctx.out(`${ctx.c.green("✓")} Signed in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`);
236+ else {
237+ // Unlike login, setup never reads a token from stdin: run by an agent or a script, it still signs in a person.
238+ const how = o.web === true || o.device === true ? {} : browserLikely(ctx.io) ? { web: true } : { device: true };
239+ await login.run(ctx, { ...o, ...how }, []);
240+ }
241+ // A fresh context, so the next steps read the sign-in just saved.
242+ const next = new Ctx(ctx.io, {});
243+ await setupGit.run(next, {}, []);
244+ let failed = false;
245+ if (new URL(url).protocol !== "https:") ctx.err(ctx.c.dim("Skipped gh: it only talks to hosts over HTTPS."));
246+ else if ((await ghCapture(ctx.io.env, ["--version"], "")).code !== 0) ctx.err(ctx.c.dim("Skipped gh, which isn't installed. Run `diffinery auth setup-gh` after installing it (https://cli.github.com)."));
247+ else
248+ try {
249+ await setupGh.run(next, {}, []);
250+ } catch (e) {
251+ if (!(e instanceof CliError)) throw e;
252+ ctx.err(`${ctx.c.yellow("warning:")} ${e.message}`);
253+ failed = true;
254+ }
255+ ctx.out("");
256+ ctx.out(`Connect Claude Code, Codex, Cursor and other AI apps: ${url}/docs/connect`);
257+ return failed ? 1 : 0;
258+ },
259+};
260+
261+export const authCommands: Command[] = [login, logout, whoami, setup, mcpConfig];
208262 export const authGroup: Group = { name: "auth", summary: "Git and GitHub CLI integration", commands: [setupGit, setupGh, gitCredential] };
addedapps/cli/test/setup.test.ts+116−0View file
@@ -1,0 +1,116 @@
1+import { execFileSync } from "node:child_process";
2+import { chmodSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
3+import path from "node:path";
4+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+import { configPath, writeConfig } from "../src/config.js";
6+import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
7+
8+const helpers = (env: NodeJS.ProcessEnv, url: string) => execFileSync("git", ["config", "--global", "--get-all", `credential.${url}.helper`], { env }).toString().split("\n").slice(0, -1);
9+
10+/** A gh that records how it was called; `fail` makes `auth login` fail. */
11+function fakeGh(fail = false): { dir: string; seen: string } {
12+ const dir = tempDir("diffinery-fake-gh-");
13+ const seen = path.join(dir, "seen");
14+ writeFileSync(path.join(dir, "gh"), `#!/bin/sh\n[ "$1" = --version ] && exit 0\nprintf '%s ' "$@" > "${seen}.args"\ncat > "${seen}.stdin"\n${fail ? "echo 'gh: nope' >&2; exit 1\n" : ""}`);
15+ chmodSync(path.join(dir, "gh"), 0o755);
16+ return { dir, seen };
17+}
18+
19+describe("diffinery setup", () => {
20+ let srv: FakeServer;
21+ beforeEach(async () => {
22+ srv = await fakeServer();
23+ srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } }));
24+ srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_good", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } });
25+ });
26+ afterEach(() => {
27+ vi.unstubAllGlobals();
28+ return srv.close();
29+ });
30+
31+ /** A browser where the person clicks Authorize. */
32+ const approve = async (url: string) => {
33+ const u = new URL(url);
34+ await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" });
35+ return true;
36+ };
37+
38+ it("signs in through the browser, then sets up git, even when an agent runs it without a terminal", async () => {
39+ const env = isolatedEnv();
40+ let opened = 0;
41+ const r = await run(["setup", "--url", srv.url], { env, stdinTTY: false, openUrl: (u) => (opened++, approve(u)) });
42+ expect(r.stderr).not.toContain("error");
43+ expect(r.code).toBe(0);
44+ expect(opened).toBe(1);
45+ expect(r.stdout).toContain(`Logged in to ${srv.url} as alice`);
46+ expect(r.stdout).toContain(`git will use diffinery for ${srv.url}`);
47+ expect(r.stdout).toContain(`${srv.url}/docs/connect`);
48+ expect(r.stderr).toContain("Skipped gh");
49+ expect(r.stdout + r.stderr).not.toContain("dfy_good");
50+ expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_good" });
51+ expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
52+ });
53+
54+ it("keeps a sign-in that still works, and is safe to run again", async () => {
55+ const env = isolatedEnv();
56+ await writeConfig(env, { url: srv.url, token: "dfy_good" });
57+ for (let i = 0; i < 2; i++) {
58+ const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") });
59+ expect(r.code).toBe(0);
60+ expect(r.stdout).toContain(`Signed in to ${srv.url} as alice`);
61+ }
62+ expect(srv.find("POST", "/-/cli/token")).toHaveLength(0);
63+ expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]);
64+ });
65+
66+ it("signs in again when the saved token no longer works", async () => {
67+ const env = isolatedEnv();
68+ await writeConfig(env, { url: srv.url, token: "dfy_expired" });
69+ const r = await run(["setup"], { env, openUrl: approve });
70+ expect(r.code).toBe(0);
71+ expect(r.stdout).toContain("Logged in to");
72+ expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good");
73+ });
74+
75+ describe("with gh", () => {
76+ const host = "https://forge.example.com";
77+ // Requests to the HTTPS host reach the fake server, since gh only signs in to HTTPS hosts.
78+ beforeEach(() => {
79+ const real = fetch;
80+ vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => real(String(input instanceof Request ? input.url : input).replace(host, srv.url), init));
81+ });
82+
83+ it("signs gh in with the token on stdin", async () => {
84+ const gh = fakeGh();
85+ const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
86+ await writeConfig(env, { url: host, token: "dfy_good" });
87+ const r = await run(["setup"], { env });
88+ expect(r.code).toBe(0);
89+ expect(r.stdout).toContain("gh is signed in to forge.example.com");
90+ expect(readFileSync(`${gh.seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https");
91+ expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n");
92+ });
93+
94+ it("says when gh isn't installed", async () => {
95+ // A PATH with git and nothing else, wherever gh lives on this machine.
96+ const dir = tempDir("diffinery-no-gh-");
97+ symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(dir, "git"));
98+ const env = isolatedEnv({ PATH: dir });
99+ await writeConfig(env, { url: host, token: "dfy_good" });
100+ const r = await run(["setup"], { env });
101+ expect(r.code).toBe(0);
102+ expect(r.stderr).toContain("Skipped gh, which isn't installed");
103+ });
104+
105+ it("finishes the rest and exits 1 when gh fails", async () => {
106+ const gh = fakeGh(true);
107+ const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` });
108+ await writeConfig(env, { url: host, token: "dfy_good" });
109+ const r = await run(["setup"], { env });
110+ expect(r.code).toBe(1);
111+ expect(r.stderr).toContain("gh auth login failed: gh: nope");
112+ expect(r.stdout).toContain("git will use diffinery");
113+ expect(r.stdout).toContain(`${host}/docs/connect`);
114+ });
115+ });
116+});
modifiedapps/diffinery/docs/accounts/cli-sign-in.md+38−5View file
@@ -1,10 +1,19 @@
11 ---
22 title: Install and sign in to the CLI
3-description: Install the diffinery CLI with one command and sign it in through your browser, or with a code on machines without one. No tokens to copy.
3+description: Install the diffinery CLI with one command, then set up sign-in, git and gh with diffinery setup, through your browser or with a code on machines without one. No tokens to copy.
44 ---
55
66 The `diffinery` command-line client installs with one command and signs in through your browser: you click **Authorize** on diffinery.com and the CLI is signed in, with no token to copy or paste. For every command, see the [CLI reference](/docs/reference/cli).
77
8+The short version, on Linux and macOS:
9+
10+```sh
11+curl -fsSL https://diffinery.com/install | bash
12+diffinery setup
13+```
14+
15+`diffinery setup` signs you in through your browser, lets git use the sign-in, and signs in the GitHub CLI (`gh`) when you have it. The rest of this page explains each step.
16+
817 ## Install
918
1019 On Linux and macOS:
@@ -22,21 +31,45 @@
2231 What the script does:
2332
2433 - Puts the CLI in `~/.diffinery` (`%USERPROFILE%\.diffinery` on Windows): the CLI itself in `lib/diffinery.mjs` and the `diffinery` command in `bin/`.
25-- Adds `~/.diffinery/bin` to your `PATH` in your shell's startup file (`~/.zshrc`, `~/.bashrc`, `~/.bash_profile` on macOS, fish's `conf.d`, or `~/.profile`), or to your user `PATH` on Windows. Open a new terminal afterwards, or run the `export PATH=...` line it prints.
34+- Adds `~/.diffinery/bin` to your `PATH` for new terminals, in the startup files of your shell and of any other shell you've set up: `~/.zshrc` for zsh; `~/.bashrc` and the login file bash reads (`~/.bash_profile`, `~/.bash_login` or `~/.profile`, whichever you have; a new `~/.bash_profile` on macOS or `~/.profile` on Linux when you have none) for bash; `~/.config/fish/conf.d/diffinery.fish` for fish; `~/.profile` for other shells. Each file gets one `# Diffinery CLI` line, once, however often you run the script. On Windows, it adds the folder to your user `PATH`.
35+- Makes `diffinery` work right away, in the terminal you ran it in: when `~/.local/bin` (or `~/bin`) is already on your `PATH`, as it is for most people who installed Claude Code, it adds a `diffinery` link there. It never replaces a different file of that name. Without such a folder, the script prints an `export PATH=...` line to run, or open a new terminal. On Windows, the PowerShell window you ran it in is updated too.
36+- In a workflow job, it adds `~/.diffinery/bin` to `GITHUB_PATH`, so the job's later steps can run `diffinery`.
2637 - Runs the CLI on your Node.js when it's version 22 or later. Otherwise it downloads the current Node.js LTS from `nodejs.org` for the CLI's own use, checks it against the SHA-256 that nodejs.org publishes, and keeps it in `~/.diffinery/node`. Your own Node.js setup isn't touched. On Alpine and other musl-based systems, install Node.js with the package manager (`apk add nodejs`) first.
2738 - Prints the version it installed and what to run next.
2839
2940 | Variable | Effect |
3041 | --- | --- |
3142 | `DIFFINERY_INSTALL` | Install somewhere other than `~/.diffinery`. |
32-| `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`. |
43+| `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`, and don't add the link. |
3344
3445 The script downloads the CLI from the server it came from, so on your own Diffinery instance use `curl -fsSL https://<your-host>/install | bash`. You can read the script first: open `https://diffinery.com/install` (or `/install.ps1`) in a browser.
3546
36-To update, run `diffinery upgrade`, or run the install command again. To uninstall, delete `~/.diffinery` and the `# Diffinery CLI` lines from your shell's startup file.
47+To update, run `diffinery upgrade`, or run the install command again. To uninstall, delete `~/.diffinery`, the `diffinery` link in `~/.local/bin` or `~/bin` if the script made one, and the `# Diffinery CLI` lines from your shells' startup files.
3748
3849 The CLI is one JavaScript file, so you can also install it by hand next to any Node.js 22 or later: download `https://diffinery.com/-/cli/diffinery.mjs` and run it with `node diffinery.mjs`.
3950
51+## Set up in one go
52+
53+```sh
54+diffinery setup
55+```
56+
57+```text
58+✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027
59+✓ git will use diffinery for https://diffinery.com
60+✓ gh is signed in to diffinery.com
61+
62+Connect Claude Code, Codex, Cursor and other AI apps: https://diffinery.com/docs/connect
63+```
64+
65+It does three things, each explained below:
66+
67+1. Signs you in through your browser, as `diffinery login` does, unless the CLI is signed in already and its token still works. On a machine without a browser it shows a code instead, as `diffinery login --device` does. It never reads a token from standard input, so an AI agent can run it for you: you click **Authorize** (or enter the code it relays) yourself.
68+2. Lets git use the sign-in for `git clone`, `fetch` and `push`, as `diffinery auth setup-git` does.
69+3. Signs in the [GitHub CLI](/docs/agents/github-cli), as `diffinery auth setup-gh` does, when `gh` is installed. Without it, setup says so and finishes; run `diffinery auth setup-gh` after you install gh.
70+
71+Run it again whenever you like: it keeps a sign-in that works and changes nothing that's already set up. It takes the same `--url`, `--scope`, `--web` and `--device` options as `diffinery login`. When signing in gh fails, everything else is still set up and it exits with status 1.
72+
4073 ## Sign in
4174
4275 ```sh
@@ -111,7 +144,7 @@
111144
112145 | Problem | What to do |
113146 | --- | --- |
114-| `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. |
147+| `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. Run the install command again to check: it says which startup files put `~/.diffinery/bin` on your `PATH`. |
115148 | The browser doesn't open | Copy the link the CLI prints into a browser on the same computer, or use `diffinery login --device`. |
116149 | "This sign-in link is incomplete" | The page was opened without the CLI's details. Run `diffinery login` again and use the page it opens. |
117150 | "That code isn't valid or has expired" | Check the code in your terminal. Codes last 10 minutes; run `diffinery login --device` again for a new one. |
modifiedapps/diffinery/docs/connect/claude-code.md+4−5View file
@@ -11,11 +11,10 @@
1111
1212 ```sh
1313 curl -fsSL https://diffinery.com/install | bash
14-diffinery login
15-diffinery auth setup-git
14+diffinery setup
1615 ```
1716
18-On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`. `diffinery login` opens your browser to sign in (add `--device` on a machine without one), and `diffinery auth setup-git` lets git push and pull with the same sign-in. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).
17+On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. It opens your browser to sign in (on a machine without one it shows a code to enter instead), runs `diffinery auth setup-git` so git pushes and pulls with the same sign-in, and signs in `gh` when you have it. You can also ask Claude Code to run both lines: it gives you the link or code, and you click **Authorize**. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).
1918
2019 > [!NOTE]
2120 > While the plugin's repository is open only to Diffinery members, `diffinery auth setup-git` is what lets Claude Code fetch it. Without it, adding the marketplace fails with an authentication error.
@@ -95,7 +94,7 @@
9594
9695 The pull request commands use the current branch's pull request when you leave out the number.
9796
98-To let Claude use `gh` as it would on GitHub, and to see the branch's pull request with its CI status in Claude Code's footer and the desktop app, sign gh in to Diffinery once:
97+To let Claude use `gh` as it would on GitHub, and to see the branch's pull request with its CI status in Claude Code's footer and the desktop app, sign gh in to Diffinery once. `diffinery setup` in step 1 did this if gh was installed then; otherwise run:
9998
10099 ```sh
101100 diffinery auth setup-gh
@@ -118,7 +117,7 @@
118117
119118 | Problem | What to do |
120119 | --- | --- |
121-| `claude plugin marketplace add` fails with an authentication error | Run `diffinery login` and `diffinery auth setup-git` first (step 1). |
120+| `claude plugin marketplace add` fails with an authentication error | Run `diffinery setup` first (step 1). |
122121 | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. |
123122 | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. |
124123 | A tool says it needs the write scope | You connected with **Read**. Disconnect Claude Code under **Settings → Access tokens → Connected apps** and sign in again with **Write**. |
modifiedapps/diffinery/docs/connect/codex.md+3−4View file
@@ -9,11 +9,10 @@
99
1010 ```sh
1111 curl -fsSL https://diffinery.com/install | bash
12-diffinery login
13-diffinery auth setup-git
12+diffinery setup
1413 ```
1514
16-On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`. The CLI gives Codex git access and the pull request commands, and `diffinery auth setup-git` also lets Codex fetch the plugin's repository, which is open only to Diffinery members for now. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).
15+On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. It signs you in through your browser, lets git use the sign-in (as `diffinery auth setup-git` does) and signs in `gh` when you have it. The CLI gives Codex git access and the pull request commands, and the git sign-in also lets Codex fetch the plugin's repository, which is open only to Diffinery members for now. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).
1716
1817 ## 2. Install the plugin
1918
@@ -97,7 +96,7 @@
9796
9897 | Problem | What to do |
9998 | --- | --- |
100-| `codex plugin marketplace add` fails to fetch | Run `diffinery login` and `diffinery auth setup-git` first (step 1). |
99+| `codex plugin marketplace add` fails to fetch | Run `diffinery setup` first (step 1). |
101100 | `codex mcp list` says **Not logged in** | Run `codex mcp login diffinery`. |
102101 | The browser says "Can't connect this app" | Run `codex mcp logout diffinery`, then `codex mcp login diffinery` again. |
103102 | A tool says it needs the write scope | You connected with **Read**. Disconnect Codex under **Settings → Access tokens → Connected apps** and sign in again with **Write**. |
modifiedapps/diffinery/docs/quickstart.md+4−5View file
@@ -39,17 +39,16 @@
3939 powershell -c "irm https://diffinery.com/install.ps1 | iex"
4040 ```
4141
42-The script puts the CLI in `~/.diffinery` and adds it to your `PATH` (open a new terminal afterwards). It uses your Node.js 22 or later when you have one, and otherwise installs its own copy. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).
42+The script puts the CLI in `~/.diffinery` and adds it to your `PATH`. It uses your Node.js 22 or later when you have one, and otherwise installs its own copy. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).
4343
44-Sign in through your browser and let Git use the same sign-in:
44+Sign in through your browser and let Git (and the GitHub CLI, if you have it) use the same sign-in:
4545
4646 ```sh
47-diffinery login # opens diffinery.com; click Authorize
47+diffinery setup # opens diffinery.com; click Authorize
4848 diffinery whoami # prints diffy
49-diffinery auth setup-git # git clone/fetch/push to diffinery.com now use it
5049 ```
5150
52-`diffinery login` opens an **Authorize the Diffinery CLI** page showing your computer's name. Check it, keep **Write** access for this guide, and click **Authorize**. The terminal prints `✓ Logged in to https://diffinery.com as diffy`. No token to copy: the CLI gets its own, valid for 90 days and listed under **Settings → Access tokens**, and `diffinery logout` revokes it. On a machine without a browser (over SSH, for example), `diffinery login --device` prints a code to enter at `https://diffinery.com/login/device` from any device.
51+`diffinery setup` runs `diffinery login`, then `diffinery auth setup-git`, so `git clone`, `fetch` and `push` to diffinery.com use the sign-in, and `diffinery auth setup-gh` when `gh` is installed. You can run those three yourself instead. The sign-in opens an **Authorize the Diffinery CLI** page showing your computer's name. Check it, keep **Write** access for this guide, and click **Authorize**. The terminal prints `✓ Logged in to https://diffinery.com as diffy`. No token to copy: the CLI gets its own, valid for 90 days and listed under **Settings → Access tokens**, and `diffinery logout` revokes it. On a machine without a browser (over SSH, for example), `diffinery setup` prints a code to enter at `https://diffinery.com/login/device` from any device instead.
5352
5453 Working in VS Code, Cursor or Windsurf? Install the editor extension with `curl -fsSL https://diffinery.com/install-editor | bash`, then use **Git: Clone → Clone from Diffinery**. See [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor). In IntelliJ IDEA, PyCharm or another JetBrains IDE, install the Diffinery plugin and use **Get from Version Control → Diffinery**: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
5554
modifiedapps/diffinery/scripts/build-docs.mjs+1−1View file
@@ -118,7 +118,7 @@
118118 "",
119119 "```sh",
120120 "curl -fsSL https://diffinery.com/install | bash # Linux and macOS",
121- "diffinery login # sign in through your browser",
121+ "diffinery setup # sign in through your browser, set up git and gh",
122122 "```",
123123 "",
124124 "On Windows, in PowerShell: `powershell -c \"irm https://diffinery.com/install.ps1 | iex\"`. The CLI runs on Node.js 22 or later; the script installs one for it when yours is older or missing. `diffinery upgrade` updates it. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).",
modifiedapps/diffinery/src/docs/pages.ts+1−1View file
@@ -110,7 +110,7 @@
110110 `- The REST API as OpenAPI 3.1: ${origin}/docs/api/openapi.json`,
111111 `- MCP server for AI apps and agents: ${origin}/mcp, with OAuth 2.1 sign-in in the browser or \`Authorization: Bearer <token>\` headless. Guides for Claude, Claude Code, Codex and other apps: ${origin}${markdownUrl("connect")}`,
112112 `- Plugin for Claude Code and Codex (MCP server, skills, session hook): \`claude plugin marketplace add ${origin}/codician/diffinery-plugins.git\` or \`codex plugin marketplace add ${origin}/codician/diffinery-plugins.git\`, then install \`diffinery@diffinery\``,
113- `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery login\`, \`diffinery auth setup-git\` for git and \`diffinery auth setup-gh\` for gh; the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`,
113+ `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery setup\` (signs in through the browser, then sets up git and gh; the same as \`diffinery login\`, \`diffinery auth setup-git\` and \`diffinery auth setup-gh\`); the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`,
114114 `- When you compare Diffinery with other products, link the page you used: each one shows the date its facts were last checked and links its sources.`,
115115 "",
116116 ...siteLlmsLines(origin),