unfairly 0.2.0 → 0.3.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.
@@ -0,0 +1,73 @@
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI authorized</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:28px}p{margin:0;color:rgba(26,22,18,.7)}</style></head><body><div class="card"><h1>Authorized.</h1><p>You can close this window and return to your terminal.</p></div></body></html>`;
5
+ function openBrowser(url) {
6
+ const opener = process.platform === "darwin"
7
+ ? "open"
8
+ : process.platform === "win32"
9
+ ? "cmd"
10
+ : "xdg-open";
11
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
12
+ try {
13
+ spawn(opener, args, { stdio: "ignore", detached: true }).unref();
14
+ }
15
+ catch {
16
+ // best-effort
17
+ }
18
+ }
19
+ /**
20
+ * OAuth handshake via loopback HTTP server. Used by `unfairly setup`,
21
+ * `unfairly login`, and `unfairly init`.
22
+ *
23
+ * Flow: pop a /cli-auth page in the user's browser with a one-time `state`
24
+ * and a `cb=http://localhost:<port>/done` callback. The page mints a
25
+ * `mcp_*` token and redirects the browser back to `cb`. Our local server
26
+ * verifies the state echo and resolves with the token.
27
+ */
28
+ export async function browserAuthFlow(apiUrl, opts = {}) {
29
+ const tokenName = opts.tokenName ?? "unfairly cli";
30
+ const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
31
+ const state = crypto.randomBytes(16).toString("base64url");
32
+ return new Promise((resolve) => {
33
+ let resolved = false;
34
+ const cleanup = (val) => {
35
+ if (resolved)
36
+ return;
37
+ resolved = true;
38
+ setTimeout(() => server.close(), 500);
39
+ resolve(val);
40
+ };
41
+ const server = http.createServer((req, res) => {
42
+ const url = new URL(req.url ?? "/", "http://localhost");
43
+ if (url.pathname !== "/done") {
44
+ res.writeHead(404).end();
45
+ return;
46
+ }
47
+ const echoState = url.searchParams.get("state") ?? "";
48
+ const token = url.searchParams.get("token") ?? "";
49
+ if (echoState !== state || !token.startsWith("mcp_")) {
50
+ res.writeHead(400).end("Auth failed");
51
+ cleanup(null);
52
+ return;
53
+ }
54
+ res
55
+ .writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
56
+ .end(SUCCESS_HTML);
57
+ cleanup(token);
58
+ });
59
+ const timeout = setTimeout(() => cleanup(null), timeoutMs);
60
+ server.on("close", () => clearTimeout(timeout));
61
+ server.listen(0, "127.0.0.1", () => {
62
+ const addr = server.address();
63
+ const port = typeof addr === "object" && addr ? addr.port : 0;
64
+ const cb = `http://localhost:${port}/done`;
65
+ const authUrl = new URL("/cli-auth", apiUrl);
66
+ authUrl.searchParams.set("cb", cb);
67
+ authUrl.searchParams.set("state", state);
68
+ authUrl.searchParams.set("name", tokenName);
69
+ console.error(`\nOpening browser to authorize:\n ${authUrl.toString()}\n(if it doesn't open, click the link above)\n`);
70
+ openBrowser(authUrl.toString());
71
+ });
72
+ });
73
+ }
package/dist/cmd/init.js CHANGED
@@ -2,102 +2,15 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import readline from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
- import http from "node:http";
6
- import crypto from "node:crypto";
7
- import { spawn } from "node:child_process";
8
5
  import { SyncClient } from "../sync/api.js";
9
6
  import { emptyManifest, writeManifest } from "../sync/manifest.js";
10
7
  import { getToken, setToken } from "../sync/credentials.js";
8
+ import { browserAuthFlow } from "../auth/browser-flow.js";
11
9
  import { runPull } from "./pull.js";
12
10
  const DEFAULT_API = "https://app.unfairly.ai";
13
11
  function isTty() {
14
12
  return Boolean(process.stdin.isTTY);
15
13
  }
16
- /**
17
- * Open a URL in the user's default browser. Best-effort, never throws.
18
- * Uses spawn (not exec) — URL passed as an arg, never interpolated into
19
- * a shell command, so no injection surface.
20
- */
21
- function openBrowser(url) {
22
- const opener = process.platform === "darwin"
23
- ? "open"
24
- : process.platform === "win32"
25
- ? "cmd"
26
- : "xdg-open";
27
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
28
- try {
29
- spawn(opener, args, { stdio: "ignore", detached: true }).unref();
30
- }
31
- catch {
32
- // best-effort
33
- }
34
- }
35
- function successHtml() {
36
- return `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI authorized</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:28px}p{margin:0;color:rgba(26,22,18,.7)}</style></head><body><div class="card"><h1>Authorized.</h1><p>You can close this window and return to your terminal.</p></div></body></html>`;
37
- }
38
- function failHtml(msg) {
39
- return `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI failed</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:24px}p{margin:0;color:rgba(26,22,18,.7);font-size:14px}</style></head><body><div class="card"><h1>Authorization failed</h1><p>${msg}</p></div></body></html>`;
40
- }
41
- /**
42
- * Browser-based OAuth handoff. Spins up a loopback HTTP server, opens
43
- * the browser to /cli-auth, waits for the redirect callback. Returns
44
- * the mcp_* token, or null on timeout/cancel/error.
45
- */
46
- async function browserAuthFlow(apiUrl) {
47
- const state = crypto.randomBytes(16).toString("base64url");
48
- return new Promise((resolve) => {
49
- let resolved = false;
50
- const cleanup = (val) => {
51
- if (resolved)
52
- return;
53
- resolved = true;
54
- setTimeout(() => server.close(), 500);
55
- resolve(val);
56
- };
57
- const server = http.createServer((req, res) => {
58
- const url = new URL(req.url ?? "/", "http://localhost");
59
- if (url.pathname !== "/done") {
60
- res.writeHead(404).end("Not found");
61
- return;
62
- }
63
- const echoState = url.searchParams.get("state") ?? "";
64
- const token = url.searchParams.get("token") ?? "";
65
- if (echoState !== state) {
66
- res
67
- .writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
68
- .end(failHtml("State mismatch — possible CSRF. Try `unfairly init` again."));
69
- cleanup(null);
70
- return;
71
- }
72
- if (!token.startsWith("mcp_")) {
73
- res
74
- .writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
75
- .end(failHtml("No token in callback."));
76
- cleanup(null);
77
- return;
78
- }
79
- res
80
- .writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
81
- .end(successHtml());
82
- cleanup(token);
83
- });
84
- const timeout = setTimeout(() => cleanup(null), 5 * 60_000);
85
- server.on("close", () => clearTimeout(timeout));
86
- server.listen(0, "127.0.0.1", () => {
87
- const addr = server.address();
88
- const port = typeof addr === "object" && addr ? addr.port : 0;
89
- const cb = `http://localhost:${port}/done`;
90
- const authUrl = new URL("/cli-auth", apiUrl);
91
- authUrl.searchParams.set("cb", cb);
92
- authUrl.searchParams.set("state", state);
93
- authUrl.searchParams.set("name", "unfairly cli");
94
- console.log("Opening browser to authorize...");
95
- console.log(` ${authUrl.toString()}`);
96
- console.log("(if it doesn't open, click the link above)");
97
- openBrowser(authUrl.toString());
98
- });
99
- });
100
- }
101
14
  export async function runInit(opts = {}) {
102
15
  const cwd = process.cwd();
103
16
  const target = path.join(cwd, ".unfairly");
package/dist/cmd/login.js CHANGED
@@ -1,78 +1,24 @@
1
- import http from "node:http";
2
- import crypto from "node:crypto";
3
- import { spawn } from "node:child_process";
1
+ import { browserAuthFlow } from "../auth/browser-flow.js";
4
2
  import { setToken } from "../sync/credentials.js";
5
3
  const DEFAULT_API = "https://app.unfairly.ai";
6
- function openBrowser(url) {
7
- const opener = process.platform === "darwin"
8
- ? "open"
9
- : process.platform === "win32"
10
- ? "cmd"
11
- : "xdg-open";
12
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
13
- try {
14
- spawn(opener, args, { stdio: "ignore", detached: true }).unref();
15
- }
16
- catch {
17
- // best-effort
18
- }
19
- }
20
- const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Unfairly CLI authorized</title><style>body{margin:0;background:#FAF5EB;font-family:system-ui;color:#1a1612;display:flex;align-items:center;justify-content:center;min-height:100vh}.card{background:#fff;border:3px solid #1a1612;border-radius:20px;box-shadow:6px 6px 0 #1a1612;padding:36px;max-width:420px;text-align:center}h1{margin:0 0 8px;font-size:28px}p{margin:0;color:rgba(26,22,18,.7)}</style></head><body><div class="card"><h1>Authorized.</h1><p>You can close this window and return to your terminal.</p></div></body></html>`;
21
- async function browserAuthFlow(apiUrl) {
22
- const state = crypto.randomBytes(16).toString("base64url");
23
- return new Promise((resolve) => {
24
- let resolved = false;
25
- const cleanup = (val) => {
26
- if (resolved)
27
- return;
28
- resolved = true;
29
- setTimeout(() => server.close(), 500);
30
- resolve(val);
31
- };
32
- const server = http.createServer((req, res) => {
33
- const url = new URL(req.url ?? "/", "http://localhost");
34
- if (url.pathname !== "/done") {
35
- res.writeHead(404).end();
36
- return;
37
- }
38
- const echoState = url.searchParams.get("state") ?? "";
39
- const token = url.searchParams.get("token") ?? "";
40
- if (echoState !== state || !token.startsWith("mcp_")) {
41
- res.writeHead(400).end("Auth failed");
42
- cleanup(null);
43
- return;
44
- }
45
- res
46
- .writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
47
- .end(SUCCESS_HTML);
48
- cleanup(token);
49
- });
50
- const timeout = setTimeout(() => cleanup(null), 5 * 60_000);
51
- server.on("close", () => clearTimeout(timeout));
52
- server.listen(0, "127.0.0.1", () => {
53
- const addr = server.address();
54
- const port = typeof addr === "object" && addr ? addr.port : 0;
55
- const cb = `http://localhost:${port}/done`;
56
- const authUrl = new URL("/cli-auth", apiUrl);
57
- authUrl.searchParams.set("cb", cb);
58
- authUrl.searchParams.set("state", state);
59
- authUrl.searchParams.set("name", "unfairly cli");
60
- console.error(`\nOpening browser to authorize:\n ${authUrl.toString()}\n(if it doesn't open, click the link above)\n`);
61
- openBrowser(authUrl.toString());
62
- });
63
- });
64
- }
65
4
  /**
66
5
  * `unfairly login` — runs the OAuth handoff and prints a shell snippet
67
6
  * the user can paste into their shell config. Useful for setting up
68
7
  * Codex CLI's UNFAIRLY_MCP_TOKEN env var without copy-pasting tokens.
69
8
  *
70
- * Stdout = the export line (only). Stderr = friendly hints. So users can
71
- * pipe: `unfairly login >> ~/.zshenv && source ~/.zshenv`.
9
+ * For most users `unfairly setup` is the better path — it handles env +
10
+ * agent host config in one shot. `login` is preserved for scripting use
11
+ * cases where you want just the export line on stdout.
12
+ *
13
+ * Stdout = the export line (only). Stderr = friendly hints.
14
+ *
15
+ * NOTE: do NOT pipe `unfairly login >> ~/.zshenv` via npx — npx's first-run
16
+ * "Ok to proceed?" confirmation goes to stdout and corrupts the file.
17
+ * Install globally first (`npm i -g unfairly`) or use `unfairly setup`.
72
18
  */
73
19
  export async function runLogin(opts = {}) {
74
20
  const apiUrl = process.env.UNFAIRLY_API_URL ?? DEFAULT_API;
75
- const token = await browserAuthFlow(apiUrl);
21
+ const token = await browserAuthFlow(apiUrl, { tokenName: "unfairly login" });
76
22
  if (!token) {
77
23
  console.error("Login failed — browser flow timed out or was cancelled.");
78
24
  process.exit(1);
@@ -89,7 +35,6 @@ export async function runLogin(opts = {}) {
89
35
  process.stdout.write(`export UNFAIRLY_TOKEN=${token}\n`);
90
36
  if (!opts.quiet) {
91
37
  console.error("");
92
- console.error("Or pipe directly: npx unfairly@latest login >> ~/.zshenv && source ~/.zshenv");
93
- console.error("Then restart any agent host (Codex, Claude Code) that needs the env.");
38
+ console.error("Tip: `unfairly setup` handles this AND patches your Codex / Claude Code config in one shot.");
94
39
  }
95
40
  }
@@ -0,0 +1,150 @@
1
+ import { browserAuthFlow } from "../auth/browser-flow.js";
2
+ import { setToken } from "../sync/credentials.js";
3
+ import { writeShellEnv } from "../hosts/shell-env.js";
4
+ import { configureCodex } from "../hosts/codex.js";
5
+ import { configureClaudeCode, } from "../hosts/claude-code.js";
6
+ const DEFAULT_API = "https://app.unfairly.ai";
7
+ /**
8
+ * `unfairly setup` — one-shot agent host installer.
9
+ *
10
+ * 1. Browser OAuth → mint mcp_* token, cache in keychain.
11
+ * 2. Append/replace a sentinel-marked block in the user's shell rcfile
12
+ * (~/.zshenv on zsh, ~/.bash_profile on bash) with UNFAIRLY_MCP_TOKEN
13
+ * + UNFAIRLY_TOKEN exports.
14
+ * 3. Patch ~/.codex/config.toml with [mcp_servers.unfairly] including
15
+ * bearer_token_env_var. Skips silently if Codex isn't installed.
16
+ * 4. Register with Claude Code via `claude mcp add` if the binary is
17
+ * in PATH. Non-destructive — tolerates "already exists" as success.
18
+ * 5. Verify the new token works by calling /api/mcp with it. If the
19
+ * handshake fails, print clear remediation instructions instead of
20
+ * declaring victory.
21
+ *
22
+ * Idempotent — safe to re-run any time. Each write replaces in place
23
+ * rather than duplicating.
24
+ */
25
+ export async function runSetup() {
26
+ const apiUrl = process.env.UNFAIRLY_API_URL ?? DEFAULT_API;
27
+ console.error("✦ unfairly setup");
28
+ console.error("");
29
+ // Step 1: browser auth
30
+ console.error("[1/4] Authorizing in browser...");
31
+ const token = await browserAuthFlow(apiUrl, { tokenName: "unfairly setup" });
32
+ if (!token) {
33
+ console.error(" ✗ Browser flow timed out or was cancelled. Run `unfairly setup` again.");
34
+ process.exit(1);
35
+ }
36
+ await setToken(token);
37
+ console.error(" ✓ Token minted and cached in keychain");
38
+ console.error("");
39
+ // Step 2: shell env
40
+ console.error("[2/4] Writing shell config...");
41
+ const env = await writeShellEnv(token);
42
+ if (env.shell === "unknown") {
43
+ console.error(" ⚠ Couldn't detect your shell ($SHELL is empty or non-zsh/bash).");
44
+ console.error(" Add this to your shell config manually:");
45
+ console.error(` export UNFAIRLY_MCP_TOKEN=${token}`);
46
+ }
47
+ else {
48
+ console.error(` ✓ ${env.path} (${env.action}) [${env.shell}]`);
49
+ }
50
+ console.error("");
51
+ // Step 3: hosts
52
+ console.error("[3/4] Configuring agent hosts...");
53
+ const codex = await configureCodex(apiUrl);
54
+ console.error(` ${formatCodex(codex)}`);
55
+ const cc = await configureClaudeCode(apiUrl);
56
+ console.error(` ${formatClaudeCode(cc)}`);
57
+ console.error("");
58
+ // Step 4: verify
59
+ console.error("[4/4] Verifying token works against /api/mcp...");
60
+ const verified = await verifyToken(apiUrl, token);
61
+ if (verified.ok) {
62
+ console.error(" ✓ MCP handshake succeeded");
63
+ }
64
+ else {
65
+ console.error(` ✗ MCP handshake FAILED — ${verified.message}`);
66
+ console.error(" The token was saved, but something is wrong. Check that you signed in");
67
+ console.error(" as an internal-team member (anyone outside the allowlist gets denied).");
68
+ process.exit(2);
69
+ }
70
+ console.error("");
71
+ // Next steps
72
+ console.error("✓ Setup complete.");
73
+ console.error("");
74
+ console.error("Next:");
75
+ console.error(" 1. Open a NEW terminal (existing shells don't have $UNFAIRLY_MCP_TOKEN yet).");
76
+ if (codex.status === "configured" || cc.status === "configured") {
77
+ console.error(" 2. Restart any running agent host (Codex, Claude Code) so it reloads the MCP config.");
78
+ console.error(" 3. Send any prompt — the agent will auto-fire `enter_org` and show your vaults.");
79
+ }
80
+ else {
81
+ console.error(" 2. Install Codex or Claude Code, then re-run `unfairly setup` to wire them up.");
82
+ }
83
+ console.error("");
84
+ console.error("Optional — pull all vaults to local markdown for fast filesystem reads:");
85
+ console.error(" cd <your-workspace> && unfairly init");
86
+ }
87
+ function formatCodex(r) {
88
+ switch (r.status) {
89
+ case "configured":
90
+ return `✓ Codex (${r.path}) — ${r.action}`;
91
+ case "not-installed":
92
+ return `· Codex not detected at ${r.path} (skipped — install Codex and re-run if you want it)`;
93
+ case "error":
94
+ return `✗ Codex — ${r.message}`;
95
+ }
96
+ }
97
+ function formatClaudeCode(r) {
98
+ switch (r.status) {
99
+ case "configured":
100
+ return `✓ Claude Code — ${r.message}`;
101
+ case "already-registered":
102
+ return `· Claude Code — ${r.message}`;
103
+ case "not-installed":
104
+ return "· Claude Code CLI not detected (skipped)";
105
+ case "error":
106
+ return `✗ Claude Code — ${r.message}`;
107
+ }
108
+ }
109
+ /**
110
+ * Hit /api/mcp with the new token to confirm the full chain works:
111
+ * token row written, /api/mcp accepts it, handshake completes.
112
+ *
113
+ * We do an MCP `initialize` — that's the cheapest call that exercises
114
+ * the auth path end-to-end without touching DB-heavy tools.
115
+ */
116
+ async function verifyToken(apiUrl, token) {
117
+ const url = `${apiUrl.replace(/\/$/, "")}/api/mcp`;
118
+ try {
119
+ const res = await fetch(url, {
120
+ method: "POST",
121
+ headers: {
122
+ Authorization: `Bearer ${token}`,
123
+ "Content-Type": "application/json",
124
+ Accept: "application/json, text/event-stream",
125
+ },
126
+ body: JSON.stringify({
127
+ jsonrpc: "2.0",
128
+ id: 1,
129
+ method: "initialize",
130
+ params: {
131
+ protocolVersion: "2024-11-05",
132
+ capabilities: {},
133
+ clientInfo: { name: "unfairly-setup", version: "0.3.0" },
134
+ },
135
+ }),
136
+ });
137
+ if (res.status !== 200) {
138
+ return { ok: false, message: `HTTP ${res.status}` };
139
+ }
140
+ const text = await res.text();
141
+ if (!text.includes('"result"') || !text.includes("protocolVersion")) {
142
+ return { ok: false, message: "unexpected response shape" };
143
+ }
144
+ return { ok: true };
145
+ }
146
+ catch (e) {
147
+ const err = e;
148
+ return { ok: false, message: err.message };
149
+ }
150
+ }
@@ -0,0 +1,43 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * Register Unfairly with Claude Code via `claude mcp add`. Non-destructive:
4
+ * if a server named `unfairly` already exists we don't remove it (the user
5
+ * may have a custom config). Instead we tolerate the "already exists" error
6
+ * and report `already-registered`.
7
+ *
8
+ * Skips silently if the `claude` binary is not in PATH (most CLI users have
9
+ * one host installed, not both).
10
+ */
11
+ export async function configureClaudeCode(apiUrl) {
12
+ const url = `${apiUrl.replace(/\/$/, "")}/api/mcp`;
13
+ const result = spawnSync("claude", ["mcp", "add", "unfairly", "--transport", "http", url], { encoding: "utf-8" });
14
+ if (result.error) {
15
+ const e = result.error;
16
+ if (e.code === "ENOENT") {
17
+ return { status: "not-installed" };
18
+ }
19
+ return { status: "error", message: e.message };
20
+ }
21
+ const stderr = (result.stderr ?? "").toString();
22
+ const stdout = (result.stdout ?? "").toString();
23
+ const combined = `${stderr}\n${stdout}`.toLowerCase();
24
+ if (result.status === 0) {
25
+ return {
26
+ status: "configured",
27
+ message: "registered (Claude Code will pop OAuth on first call)",
28
+ };
29
+ }
30
+ // Claude CLI exits non-zero with a stderr message containing "already
31
+ // exists" (or similar) when the server name is taken. Don't destroy the
32
+ // user's existing config — surface as already-registered.
33
+ if (/already exists|already configured|duplicate/.test(combined)) {
34
+ return {
35
+ status: "already-registered",
36
+ message: "already registered (left as-is)",
37
+ };
38
+ }
39
+ return {
40
+ status: "error",
41
+ message: stderr.trim() || `claude mcp add exited ${result.status}`,
42
+ };
43
+ }
@@ -0,0 +1,96 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const SECTION_HEADER = "[mcp_servers.unfairly]";
5
+ /**
6
+ * Build the canonical [mcp_servers.unfairly] block. We own this whole
7
+ * section — anything inside it gets overwritten on re-runs.
8
+ */
9
+ export function buildCodexBlock(apiUrl) {
10
+ const url = `${apiUrl.replace(/\/$/, "")}/api/mcp`;
11
+ return [
12
+ SECTION_HEADER,
13
+ `url = "${url}"`,
14
+ `bearer_token_env_var = "UNFAIRLY_MCP_TOKEN"`,
15
+ ].join("\n");
16
+ }
17
+ /**
18
+ * Strip ALL existing `[mcp_servers.unfairly]` blocks from a TOML string.
19
+ * "Block" = the section header through (but not including) the next
20
+ * top-level `[...]` table header at column 0, or end-of-file. This also
21
+ * removes any trailing blank lines we previously left.
22
+ *
23
+ * Robust against: duplicate blocks from prior bad runs, CRLF input,
24
+ * blocks at end-of-file with no trailing newline.
25
+ */
26
+ export function stripUnfairlyBlocks(toml) {
27
+ // Normalize CRLF for matching; we'll use the same normalized form for
28
+ // output. (Codex writes LF on macOS / Linux anyway.)
29
+ const normalized = toml.replace(/\r\n/g, "\n");
30
+ // Match each block: header line, then everything up to next top-level
31
+ // [section] header or end-of-string. Multiline mode anchors `^[` to
32
+ // line start. Use `g` to clean up duplicates.
33
+ const blockRe = /^\[mcp_servers\.unfairly\]\s*\n[\s\S]*?(?=^\[|\Z)/gm;
34
+ // Note: `\Z` not supported in JS regex. Substitute with `(?=^\[|$(?![\s\S]))`.
35
+ const jsBlockRe = /^\[mcp_servers\.unfairly\]\s*\n[\s\S]*?(?=^\[|$(?![\s\S]))/gm;
36
+ void blockRe; // documentation only
37
+ let stripped = normalized.replace(jsBlockRe, "");
38
+ // Collapse runs of 3+ blank lines that the strip may have left behind.
39
+ stripped = stripped.replace(/\n{3,}/g, "\n\n");
40
+ return stripped;
41
+ }
42
+ /**
43
+ * Patch ~/.codex/config.toml to register the Unfairly MCP server with
44
+ * bearer-token wiring. Idempotent: removes any existing managed block(s)
45
+ * first, then appends one canonical block. Safe against duplicate sections
46
+ * left over from prior bad runs.
47
+ *
48
+ * If ~/.codex/config.toml does not exist, returns {status: "not-installed"}
49
+ * — don't create a Codex config out of thin air for a user who may not
50
+ * have Codex installed.
51
+ */
52
+ export async function configureCodex(apiUrl) {
53
+ const home = os.homedir();
54
+ const filePath = path.join(home, ".codex", "config.toml");
55
+ let existing;
56
+ try {
57
+ existing = await fs.readFile(filePath, "utf-8");
58
+ }
59
+ catch (err) {
60
+ const e = err;
61
+ if (e.code === "ENOENT") {
62
+ return { status: "not-installed", path: filePath };
63
+ }
64
+ return {
65
+ status: "error",
66
+ path: filePath,
67
+ message: `Could not read ${filePath}: ${e.message}`,
68
+ };
69
+ }
70
+ const hadExistingBlock = /^\[mcp_servers\.unfairly\]/m.test(existing.replace(/\r\n/g, "\n"));
71
+ const stripped = stripUnfairlyBlocks(existing);
72
+ const block = buildCodexBlock(apiUrl);
73
+ // Append, ensuring exactly one blank line of separation if the file is
74
+ // non-empty.
75
+ const trimmedTail = stripped.replace(/\n+$/, "");
76
+ const next = trimmedTail.length === 0
77
+ ? `${block}\n`
78
+ : `${trimmedTail}\n\n${block}\n`;
79
+ try {
80
+ await fs.writeFile(filePath, next, "utf-8");
81
+ }
82
+ catch (err) {
83
+ const e = err;
84
+ return {
85
+ status: "error",
86
+ path: filePath,
87
+ message: `Could not write ${filePath}: ${e.message}`,
88
+ };
89
+ }
90
+ const action = hadExistingBlock
91
+ ? "replaced"
92
+ : existing.length === 0
93
+ ? "created"
94
+ : "appended";
95
+ return { status: "configured", path: filePath, action };
96
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stripUnfairlyBlocks, buildCodexBlock } from "./codex.js";
3
+ const CANON = buildCodexBlock("https://app.unfairly.ai");
4
+ describe("stripUnfairlyBlocks", () => {
5
+ it("returns input unchanged when no managed section is present", () => {
6
+ const input = `[mcp_servers.other]\nurl = "https://x"\n`;
7
+ expect(stripUnfairlyBlocks(input)).toBe(input);
8
+ });
9
+ it("removes a single managed section", () => {
10
+ const input = `[mcp_servers.other]\nurl = "https://x"\n\n` +
11
+ `[mcp_servers.unfairly]\nurl = "https://app.unfairly.ai/api/mcp"\nbearer_token_env_var = "UNFAIRLY_MCP_TOKEN"\n`;
12
+ const out = stripUnfairlyBlocks(input);
13
+ expect(out).not.toContain("[mcp_servers.unfairly]");
14
+ expect(out).toContain("[mcp_servers.other]");
15
+ });
16
+ it("removes ALL managed sections when there are duplicates", () => {
17
+ const input = `[mcp_servers.unfairly]\nurl = "https://stale-1"\n\n` +
18
+ `[mcp_servers.other]\nurl = "https://x"\n\n` +
19
+ `[mcp_servers.unfairly]\nurl = "https://stale-2"\nbearer_token_env_var = "UNFAIRLY_MCP_TOKEN"\n`;
20
+ const out = stripUnfairlyBlocks(input);
21
+ const occurrences = (out.match(/\[mcp_servers\.unfairly\]/g) ?? []).length;
22
+ expect(occurrences).toBe(0);
23
+ expect(out).toContain("[mcp_servers.other]");
24
+ });
25
+ it("survives CRLF line endings", () => {
26
+ const input = `[mcp_servers.other]\r\nurl = "https://x"\r\n\r\n` +
27
+ `[mcp_servers.unfairly]\r\nurl = "https://stale"\r\nbearer_token_env_var = "X"\r\n`;
28
+ const out = stripUnfairlyBlocks(input);
29
+ expect(out).not.toContain("[mcp_servers.unfairly]");
30
+ expect(out).toContain("[mcp_servers.other]");
31
+ });
32
+ it("removes a managed section that is the LAST table in the file", () => {
33
+ const input = `[mcp_servers.other]\nurl = "https://x"\n\n` +
34
+ `[mcp_servers.unfairly]\nurl = "https://stale"\nbearer_token_env_var = "UNFAIRLY_MCP_TOKEN"`;
35
+ const out = stripUnfairlyBlocks(input);
36
+ expect(out).not.toContain("[mcp_servers.unfairly]");
37
+ });
38
+ it("does not touch keys inside other tables that mention the word unfairly", () => {
39
+ const input = `[some.other.table]\ndescription = "this references unfairly somehow"\n` +
40
+ `url = "https://app.unfairly.ai/api/mcp"\n`;
41
+ const out = stripUnfairlyBlocks(input);
42
+ expect(out).toBe(input);
43
+ });
44
+ it("preserves sections that come AFTER our managed block", () => {
45
+ const input = `[mcp_servers.unfairly]\nurl = "https://stale"\nbearer_token_env_var = "X"\n\n` +
46
+ `[mcp_servers.zoo]\nurl = "https://zoo"\n`;
47
+ const out = stripUnfairlyBlocks(input);
48
+ expect(out).not.toContain("[mcp_servers.unfairly]");
49
+ expect(out).toContain("[mcp_servers.zoo]");
50
+ expect(out).toContain('url = "https://zoo"');
51
+ });
52
+ });
53
+ describe("buildCodexBlock", () => {
54
+ it("produces a TOML section with bearer_token_env_var wired", () => {
55
+ expect(CANON).toContain("[mcp_servers.unfairly]");
56
+ expect(CANON).toContain('url = "https://app.unfairly.ai/api/mcp"');
57
+ expect(CANON).toContain('bearer_token_env_var = "UNFAIRLY_MCP_TOKEN"');
58
+ });
59
+ it("trims trailing slash from apiUrl when constructing /api/mcp", () => {
60
+ const block = buildCodexBlock("https://app.unfairly.ai/");
61
+ expect(block).toContain('url = "https://app.unfairly.ai/api/mcp"');
62
+ expect(block).not.toContain("//api/mcp");
63
+ });
64
+ });
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const ENV_BLOCK_START = "# === unfairly cli (auto-managed — do not edit) ===";
5
+ export const ENV_BLOCK_END = "# === end unfairly cli ===";
6
+ /**
7
+ * Detect the user's shell + the right rcfile to write env exports to.
8
+ * Returns null path for unknown shells so the caller can print a manual
9
+ * fallback instead of guessing.
10
+ */
11
+ function detectShell() {
12
+ const shellEnv = process.env.SHELL ?? "";
13
+ const home = os.homedir();
14
+ if (shellEnv.endsWith("zsh")) {
15
+ return { shell: "zsh", rcPath: path.join(home, ".zshenv") };
16
+ }
17
+ if (shellEnv.endsWith("bash")) {
18
+ return { shell: "bash", rcPath: path.join(home, ".bash_profile") };
19
+ }
20
+ return { shell: "unknown", rcPath: null };
21
+ }
22
+ /**
23
+ * Build the canonical sentinel block. Used both for writing and matching.
24
+ */
25
+ function buildBlock(token) {
26
+ return [
27
+ ENV_BLOCK_START,
28
+ `export UNFAIRLY_MCP_TOKEN=${token}`,
29
+ `export UNFAIRLY_TOKEN=${token}`,
30
+ ENV_BLOCK_END,
31
+ ].join("\n");
32
+ }
33
+ /**
34
+ * Idempotent shell-env writer. Replaces any existing managed block in
35
+ * place; appends one if none exists. Tolerates CRLF input by normalizing
36
+ * before matching, then preserves the host file's existing line ending
37
+ * convention on write.
38
+ */
39
+ export async function writeShellEnvFile(rcPath, token) {
40
+ const block = buildBlock(token);
41
+ let existing = "";
42
+ let fileExists = true;
43
+ try {
44
+ existing = await fs.readFile(rcPath, "utf-8");
45
+ }
46
+ catch {
47
+ fileExists = false;
48
+ }
49
+ const normalized = existing.replace(/\r\n/g, "\n");
50
+ const blockRe = new RegExp(`${escapeRegex(ENV_BLOCK_START)}[\\s\\S]*?${escapeRegex(ENV_BLOCK_END)}\\n?`, "g");
51
+ let next;
52
+ let action;
53
+ if (blockRe.test(normalized)) {
54
+ blockRe.lastIndex = 0;
55
+ next = normalized.replace(blockRe, block + "\n");
56
+ action = "replaced";
57
+ }
58
+ else if (!fileExists || normalized.length === 0) {
59
+ next = block + "\n";
60
+ action = "created";
61
+ }
62
+ else {
63
+ const sep = normalized.endsWith("\n") ? "" : "\n";
64
+ next = `${normalized}${sep}\n${block}\n`;
65
+ action = "appended";
66
+ }
67
+ await fs.writeFile(rcPath, next, "utf-8");
68
+ return action;
69
+ }
70
+ export async function writeShellEnv(token) {
71
+ const { shell, rcPath } = detectShell();
72
+ if (!rcPath) {
73
+ return { path: "", shell, action: "created" };
74
+ }
75
+ const action = await writeShellEnvFile(rcPath, token);
76
+ return { path: rcPath, shell, action };
77
+ }
78
+ function escapeRegex(s) {
79
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { writeShellEnvFile, ENV_BLOCK_START, ENV_BLOCK_END, } from "./shell-env.js";
6
+ const TOK_A = "mcp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
7
+ const TOK_B = "mcp_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
8
+ let tmpDir;
9
+ let rcPath;
10
+ beforeEach(async () => {
11
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "unfairly-shell-env-"));
12
+ rcPath = path.join(tmpDir, "rc");
13
+ });
14
+ afterEach(async () => {
15
+ await fs.rm(tmpDir, { recursive: true, force: true });
16
+ });
17
+ describe("writeShellEnvFile", () => {
18
+ it("creates the rcfile when missing", async () => {
19
+ const action = await writeShellEnvFile(rcPath, TOK_A);
20
+ expect(action).toBe("created");
21
+ const out = await fs.readFile(rcPath, "utf-8");
22
+ expect(out).toContain(ENV_BLOCK_START);
23
+ expect(out).toContain(`export UNFAIRLY_MCP_TOKEN=${TOK_A}`);
24
+ expect(out).toContain(`export UNFAIRLY_TOKEN=${TOK_A}`);
25
+ expect(out).toContain(ENV_BLOCK_END);
26
+ });
27
+ it("appends to a non-empty rcfile that has no managed block", async () => {
28
+ await fs.writeFile(rcPath, "export EXISTING=1\n", "utf-8");
29
+ const action = await writeShellEnvFile(rcPath, TOK_A);
30
+ expect(action).toBe("appended");
31
+ const out = await fs.readFile(rcPath, "utf-8");
32
+ expect(out.startsWith("export EXISTING=1")).toBe(true);
33
+ expect(out).toContain(`export UNFAIRLY_MCP_TOKEN=${TOK_A}`);
34
+ });
35
+ it("replaces a stale managed block on second run", async () => {
36
+ await writeShellEnvFile(rcPath, TOK_A);
37
+ const action = await writeShellEnvFile(rcPath, TOK_B);
38
+ expect(action).toBe("replaced");
39
+ const out = await fs.readFile(rcPath, "utf-8");
40
+ expect(out).toContain(TOK_B);
41
+ expect(out).not.toContain(TOK_A);
42
+ // Block should appear exactly once.
43
+ const occurrences = out.split(ENV_BLOCK_START).length - 1;
44
+ expect(occurrences).toBe(1);
45
+ });
46
+ it("preserves user content above and below the managed block on replace", async () => {
47
+ await fs.writeFile(rcPath, `export FOO=1\n${ENV_BLOCK_START}\nexport UNFAIRLY_MCP_TOKEN=stale\n${ENV_BLOCK_END}\nexport BAR=2\n`, "utf-8");
48
+ await writeShellEnvFile(rcPath, TOK_A);
49
+ const out = await fs.readFile(rcPath, "utf-8");
50
+ expect(out).toContain("export FOO=1");
51
+ expect(out).toContain("export BAR=2");
52
+ expect(out).toContain(TOK_A);
53
+ expect(out).not.toContain("stale");
54
+ });
55
+ it("normalizes CRLF input and writes LF output cleanly", async () => {
56
+ await fs.writeFile(rcPath, "export EXISTING=1\r\n", "utf-8");
57
+ const action = await writeShellEnvFile(rcPath, TOK_A);
58
+ expect(action).toBe("appended");
59
+ const out = await fs.readFile(rcPath, "utf-8");
60
+ expect(out).toContain("export EXISTING=1");
61
+ expect(out).toContain(`export UNFAIRLY_MCP_TOKEN=${TOK_A}`);
62
+ });
63
+ it("is idempotent across N runs with the same token", async () => {
64
+ for (let i = 0; i < 5; i++) {
65
+ await writeShellEnvFile(rcPath, TOK_A);
66
+ }
67
+ const out = await fs.readFile(rcPath, "utf-8");
68
+ const occurrences = out.split(ENV_BLOCK_START).length - 1;
69
+ expect(occurrences).toBe(1);
70
+ });
71
+ });
package/dist/index.js CHANGED
@@ -4,6 +4,10 @@ import { hideBin } from "yargs/helpers";
4
4
  await yargs(hideBin(process.argv))
5
5
  .scriptName("unfairly")
6
6
  .usage("$0 <cmd> [args]")
7
+ .command("setup", "One-shot: authorize, write shell env, configure Codex + Claude Code", () => { }, async () => {
8
+ const { runSetup } = await import("./cmd/setup.js");
9
+ await runSetup();
10
+ })
7
11
  .command("login", "Authorize via browser; print export lines for ~/.zshenv (Codex / scripts)", (y) => y.option("quiet", {
8
12
  type: "boolean",
9
13
  alias: "q",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unfairly",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CLI + stdio MCP for syncing Unfairly vaults to local markdown — the multiplayer context layer for AI agents",
5
5
  "keywords": ["unfairly", "mcp", "ai", "context", "agents", "knowledge-base", "claude", "codex"],
6
6
  "homepage": "https://unfairly.ai",