mostcracked 1.0.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 +46 -0
- package/bin/ccrank.mjs +555 -0
- package/lib/backfill.mjs +158 -0
- package/lib/hook.mjs +79 -0
- package/lib/statusline.mjs +195 -0
- package/lib/update-check.mjs +71 -0
- package/package.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# mostcracked
|
|
2
|
+
|
|
3
|
+
The global **coding-agent leaderboard** — [mostcracked.com](https://mostcracked.com).
|
|
4
|
+
|
|
5
|
+
Sign in with GitHub, and every prompt you send and every file your agent edits
|
|
6
|
+
scores you a point. Works with Claude Code and Codex. Live on the web dashboard
|
|
7
|
+
and right in your terminal statusline.
|
|
8
|
+
|
|
9
|
+
It counts **prompts** and **file edits** via your agent's own hooks. It sends
|
|
10
|
+
**counts and metadata only, never your code**.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
🥇 jay prompts 128 edits 94 score 222
|
|
14
|
+
🥈 ada prompts 111 edits 80 score 191
|
|
15
|
+
🥉 linus prompts 76 edits 60 score 136
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Get on the board
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx mostcracked login
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Sign in with GitHub (device flow: enter a one-time code at
|
|
25
|
+
`github.com/login/device`, or reuse your `gh` CLI session), restart your agent,
|
|
26
|
+
and your prompts and edits start counting. Check your rank anytime with
|
|
27
|
+
`npx mostcracked status` or at [mostcracked.com](https://mostcracked.com).
|
|
28
|
+
|
|
29
|
+
New machine? Just `npx mostcracked login` again. GitHub is your identity, so
|
|
30
|
+
there's nothing else to recover.
|
|
31
|
+
|
|
32
|
+
**No fresh start at the bottom:** on sign-in it offers to backfill your last 7
|
|
33
|
+
days from your local agent history — per-day **counts only, never your code**,
|
|
34
|
+
one shot per account.
|
|
35
|
+
|
|
36
|
+
**Stays fresh by itself:** the installed hook quietly picks up new versions of
|
|
37
|
+
these scripts. You never run `update` by hand.
|
|
38
|
+
|
|
39
|
+
To stop: `npx mostcracked leave`.
|
|
40
|
+
|
|
41
|
+
## Privacy
|
|
42
|
+
|
|
43
|
+
- Identity is real GitHub auth (`read:user` scope — public profile only). It
|
|
44
|
+
can't touch your code, repos, or orgs.
|
|
45
|
+
- Only event counts leave your machine. No prompts, no diffs, no file
|
|
46
|
+
contents. Ever.
|
package/bin/ccrank.mjs
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ccrank CLI — sign in with GitHub, create/join rooms, and (un)install the
|
|
3
|
+
// Claude Code hooks. Zero dependencies, no build step.
|
|
4
|
+
//
|
|
5
|
+
// USER-PRIMARY model: your GitHub account is the identity; all your prompts
|
|
6
|
+
// and edits form ONE global stream. Rooms are just groupings you're a member of.
|
|
7
|
+
//
|
|
8
|
+
// Identity is REAL GitHub auth: we get a GitHub access token (from the gh CLI
|
|
9
|
+
// if you're signed in, else GitHub's device flow) and the server verifies it
|
|
10
|
+
// with api.github.com before minting a session. No username squatting possible.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { scanHistory } from "../lib/backfill.mjs";
|
|
14
|
+
import { recordApplied } from "../lib/update-check.mjs";
|
|
15
|
+
import { execSync, spawn } from "node:child_process";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PKG_ROOT = join(HERE, "..");
|
|
22
|
+
const CC_DIR = join(homedir(), ".ccrank"); // our config + hook scripts live here
|
|
23
|
+
const CFG = join(CC_DIR, "config.json");
|
|
24
|
+
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
|
|
25
|
+
const CODEX_HOOKS = join(homedir(), ".codex", "hooks.json"); // Codex reads lifecycle hooks here
|
|
26
|
+
|
|
27
|
+
// Default server. Overridable with --server <url> or CCRANK_SERVER env.
|
|
28
|
+
const DEFAULT_SERVER = process.env.CCRANK_SERVER || "https://mostcracked.com";
|
|
29
|
+
|
|
30
|
+
// GitHub OAuth app client id for the device flow (public by design, not a
|
|
31
|
+
// secret — it only identifies the "ccrank" OAuth app). Overridable for forks.
|
|
32
|
+
const GH_CLIENT_ID = process.env.CCRANK_GH_CLIENT_ID || "Ov23lipQUJVMvnQ2gTh2";
|
|
33
|
+
|
|
34
|
+
// ---- tiny arg parsing ----------------------------------------------------
|
|
35
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
36
|
+
const flags = {};
|
|
37
|
+
const pos = [];
|
|
38
|
+
for (let i = 0; i < rest.length; i++) {
|
|
39
|
+
if (rest[i].startsWith("--")) flags[rest[i].slice(2)] = rest[++i];
|
|
40
|
+
else pos.push(rest[i]);
|
|
41
|
+
}
|
|
42
|
+
const server = (flags.server || DEFAULT_SERVER).replace(/\/$/, "");
|
|
43
|
+
|
|
44
|
+
// Which coding agent(s) to wire: 'claude' (default), 'codex', or 'both'.
|
|
45
|
+
// Scores merge across agents; this only decides which config files we touch.
|
|
46
|
+
// An explicit --agent wins; otherwise reuse whatever was installed before (so
|
|
47
|
+
// `ccrank update` on a codex/both setup doesn't silently drop back to claude).
|
|
48
|
+
const AGENT = ["claude", "codex", "both"].includes(flags.agent)
|
|
49
|
+
? flags.agent
|
|
50
|
+
: (loadConfig()?.agent || "claude");
|
|
51
|
+
const wantClaude = AGENT === "claude" || AGENT === "both";
|
|
52
|
+
const wantCodex = AGENT === "codex" || AGENT === "both";
|
|
53
|
+
const agentLabel = AGENT === "codex" ? "Codex" : AGENT === "both" ? "Claude Code and Codex" : "Claude Code";
|
|
54
|
+
function restartMsg() {
|
|
55
|
+
return c.dim(` Restart ${agentLabel} (or open a new session) to activate.\n`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
59
|
+
g: (s) => `\x1b[32m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m` };
|
|
60
|
+
|
|
61
|
+
async function api(path, method = "GET", body) {
|
|
62
|
+
const res = await fetch(server + path, {
|
|
63
|
+
method,
|
|
64
|
+
headers: body ? { "content-type": "application/json" } : {},
|
|
65
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
66
|
+
});
|
|
67
|
+
const data = await res.json().catch(() => ({}));
|
|
68
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
69
|
+
return data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadConfig() { try { return JSON.parse(readFileSync(CFG, "utf8")); } catch { return null; } }
|
|
73
|
+
function saveConfig(cfg) { mkdirSync(CC_DIR, { recursive: true }); writeFileSync(CFG, JSON.stringify(cfg, null, 2)); }
|
|
74
|
+
|
|
75
|
+
// Personalized board URL: ?me=<github_id> lets the dashboard highlight the
|
|
76
|
+
// viewer's own row. The id is PUBLIC (it's in every leaderboard row) and only
|
|
77
|
+
// drives a cosmetic highlight — never put tokens or secrets in URLs.
|
|
78
|
+
function withMe(url, cfg) {
|
|
79
|
+
if (!cfg?.githubId) return url;
|
|
80
|
+
return url + (url.includes("?") ? "&" : "?") + "me=" + cfg.githubId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Best-effort "open in browser" — silent on failure (SSH/headless/CI), the
|
|
84
|
+
// printed link always covers those cases.
|
|
85
|
+
function openBrowser(url) {
|
|
86
|
+
try {
|
|
87
|
+
const [bin, args] = process.platform === "darwin" ? ["open", [url]]
|
|
88
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
89
|
+
: ["xdg-open", [url]];
|
|
90
|
+
const child = spawn(bin, args, { detached: true, stdio: "ignore" });
|
|
91
|
+
child.on("error", () => {});
|
|
92
|
+
child.unref();
|
|
93
|
+
return true;
|
|
94
|
+
} catch { return false; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- GitHub auth ---------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// Shortcut: you're signed into the gh CLI — its token identifies you without
|
|
100
|
+
// the browser step. We OFFER it; device flow remains the canonical path.
|
|
101
|
+
function ghCliToken() {
|
|
102
|
+
try {
|
|
103
|
+
const out = execSync("gh auth token", { stdio: ["ignore", "pipe", "ignore"], timeout: 4000 })
|
|
104
|
+
.toString().trim();
|
|
105
|
+
return /^(gho|ghp|ghu|github_pat)_[A-Za-z0-9_]+$/.test(out) ? out : null;
|
|
106
|
+
} catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
110
|
+
|
|
111
|
+
// One y/n question on the terminal; non-interactive runs take the default.
|
|
112
|
+
async function askYesNo(question, def = true) {
|
|
113
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return def;
|
|
114
|
+
const { createInterface } = await import("node:readline/promises");
|
|
115
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
116
|
+
try {
|
|
117
|
+
const a = (await rl.question(` ${question} ${def ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
118
|
+
return a === "" ? def : a === "y" || a === "yes";
|
|
119
|
+
} finally { rl.close(); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Copy text to the OS clipboard, best-effort and silent on failure.
|
|
123
|
+
function toClipboard(text) {
|
|
124
|
+
try {
|
|
125
|
+
const [bin, args] = process.platform === "darwin" ? ["pbcopy", []]
|
|
126
|
+
: process.platform === "win32" ? ["clip", []]
|
|
127
|
+
: ["xclip", ["-selection", "clipboard"]];
|
|
128
|
+
const child = spawn(bin, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
129
|
+
child.on("error", () => {});
|
|
130
|
+
child.stdin.on("error", () => {});
|
|
131
|
+
child.stdin.write(text); child.stdin.end();
|
|
132
|
+
return true;
|
|
133
|
+
} catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// GitHub device flow. The CLI does ALL the legwork itself — opens the GitHub
|
|
137
|
+
// page in the browser AND puts the code in the clipboard — so the user just
|
|
138
|
+
// pastes and clicks. The printed code is the fallback for SSH/headless.
|
|
139
|
+
// scope read:user: public profile only; can't touch code, repos, or orgs.
|
|
140
|
+
async function deviceFlowToken() {
|
|
141
|
+
const start = await (await fetch("https://github.com/login/device/code", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({ client_id: GH_CLIENT_ID, scope: "read:user" }),
|
|
145
|
+
})).json();
|
|
146
|
+
if (!start?.device_code) { fail("Could not start GitHub sign-in. Try again."); return null; }
|
|
147
|
+
|
|
148
|
+
const uri = start.verification_uri || "https://github.com/login/device";
|
|
149
|
+
const copied = toClipboard(start.user_code);
|
|
150
|
+
const opened = openBrowser(uri);
|
|
151
|
+
// Native notification so the code reaches the user even if this output is
|
|
152
|
+
// buried (e.g. an agent ran us in the background and forgot to relay it).
|
|
153
|
+
if (process.platform === "darwin") {
|
|
154
|
+
try {
|
|
155
|
+
const note = `Code ${start.user_code} ${copied ? "copied, just paste it" : ""} on the GitHub page`;
|
|
156
|
+
spawn("osascript", ["-e",
|
|
157
|
+
`display notification ${JSON.stringify(note)} with title "ccrank" subtitle "GitHub sign-in"`],
|
|
158
|
+
{ detached: true, stdio: "ignore" }).unref();
|
|
159
|
+
} catch { /* cosmetic */ }
|
|
160
|
+
}
|
|
161
|
+
console.log(`\n Sign in with GitHub to prove who you are.`);
|
|
162
|
+
if (opened) console.log(` ${c.g("✓")} GitHub just opened in your browser.${copied ? ` The code's in your clipboard, so just paste it.` : ""}`);
|
|
163
|
+
console.log(`\n Code: ${c.y(start.user_code)}${copied ? c.dim(" (copied to clipboard)") : ""}`);
|
|
164
|
+
console.log(` Page: ${c.b(uri)}${opened ? c.dim(" (already open)") : ""}`);
|
|
165
|
+
console.log(c.dim(`\n Tip: GitHub's green Authorize button wakes up after a second or two.`));
|
|
166
|
+
console.log(c.dim(` Waiting for you to authorize (Ctrl-C to abort)…`));
|
|
167
|
+
|
|
168
|
+
let interval = (start.interval || 5) * 1000;
|
|
169
|
+
const deadline = Date.now() + Math.min((start.expires_in || 900) * 1000, 10 * 60 * 1000);
|
|
170
|
+
while (Date.now() < deadline) {
|
|
171
|
+
await sleep(interval);
|
|
172
|
+
const res = await (await fetch("https://github.com/login/oauth/access_token", {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({
|
|
176
|
+
client_id: GH_CLIENT_ID,
|
|
177
|
+
device_code: start.device_code,
|
|
178
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
179
|
+
}),
|
|
180
|
+
})).json().catch(() => ({}));
|
|
181
|
+
if (res.access_token) return res.access_token;
|
|
182
|
+
if (res.error === "authorization_pending") continue;
|
|
183
|
+
if (res.error === "slow_down") { interval += 5000; continue; }
|
|
184
|
+
if (res.error === "access_denied") { fail("GitHub sign-in was denied."); return null; }
|
|
185
|
+
if (res.error === "expired_token") break;
|
|
186
|
+
}
|
|
187
|
+
fail("GitHub sign-in timed out. Run the command again.");
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Ensure we have a GitHub-verified session: reuse the saved one, else sign in.
|
|
192
|
+
// Returns the config object (NOT yet saved), or null on failure.
|
|
193
|
+
async function ensureUser({ force = false } = {}) {
|
|
194
|
+
const prev = loadConfig();
|
|
195
|
+
if (!force && prev?.token && prev?.login) return { ...prev, server: prev.server || server };
|
|
196
|
+
|
|
197
|
+
let ghToken = null;
|
|
198
|
+
const cliToken = ghCliToken();
|
|
199
|
+
if (cliToken && (await askYesNo("You're signed into the gh CLI. Sign in to ccrank with it (skips the browser step)?"))) {
|
|
200
|
+
ghToken = cliToken;
|
|
201
|
+
}
|
|
202
|
+
if (!ghToken) ghToken = await deviceFlowToken();
|
|
203
|
+
if (!ghToken) return null;
|
|
204
|
+
try {
|
|
205
|
+
const res = await api("/api/login", "POST", { ghToken });
|
|
206
|
+
return {
|
|
207
|
+
server, token: res.token, githubId: res.githubId || null,
|
|
208
|
+
login: res.login, avatar: res.avatar || null,
|
|
209
|
+
reclaimed: !!res.reclaimed,
|
|
210
|
+
roomCode: prev?.roomCode || null, roomName: prev?.roomName || null,
|
|
211
|
+
wrappedStatusLine: prev?.wrappedStatusLine || null,
|
|
212
|
+
};
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (e.message === "github_rejected") {
|
|
215
|
+
fail(`GitHub didn't accept that sign-in. Try again (or "gh auth login" first if you use the gh CLI).`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- install plumbing ----------------------------------------------------
|
|
223
|
+
|
|
224
|
+
// Copy hook.mjs + statusline.mjs into ~/.ccrank so Claude Code can run them.
|
|
225
|
+
function installScripts() {
|
|
226
|
+
mkdirSync(CC_DIR, { recursive: true });
|
|
227
|
+
for (const f of ["hook.mjs", "statusline.mjs", "update-check.mjs"])
|
|
228
|
+
copyFileSync(join(PKG_ROOT, "lib", f), join(CC_DIR, f));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Record which version is now installed so the auto-update check doesn't
|
|
232
|
+
// spawn a pointless update. `update --applied-version <v>` knows exactly (the
|
|
233
|
+
// auto-updater pins the version it ran; legacy pre-npm updaters pass
|
|
234
|
+
// --applied-sha); every other path asks the server, best-effort — worst case
|
|
235
|
+
// is one redundant background update tomorrow.
|
|
236
|
+
async function recordVersion() {
|
|
237
|
+
if (flags["applied-version"]) { recordApplied(flags["applied-version"]); return; }
|
|
238
|
+
if (flags["applied-sha"]) { recordApplied(flags["applied-sha"]); return; }
|
|
239
|
+
const ctrl = new AbortController();
|
|
240
|
+
const timer = setTimeout(() => ctrl.abort(), 2500);
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetch(server + "/api/client-version", { signal: ctrl.signal });
|
|
243
|
+
const v = (await res.json())?.version;
|
|
244
|
+
if (v) recordApplied(v);
|
|
245
|
+
} catch { /* cosmetic */ } finally { clearTimeout(timer); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Remove any ccrank hook entries from a settings/hooks object, in place. Run
|
|
249
|
+
// before re-adding so upgrades never leave a stale command behind (e.g. the
|
|
250
|
+
// old source-less `hook.mjs prompt` alongside the new `hook.mjs prompt claude`,
|
|
251
|
+
// which would double-count).
|
|
252
|
+
function purgeHooks(s) {
|
|
253
|
+
for (const ev of Object.keys(s.hooks || {})) {
|
|
254
|
+
s.hooks[ev] = (s.hooks[ev] || []).filter((g) => !JSON.stringify(g).includes("hook.mjs"));
|
|
255
|
+
if (!s.hooks[ev].length) delete s.hooks[ev];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Build the two hook entries for a given agent's hook object. `source` is baked
|
|
260
|
+
// into the command so the same hook.mjs reports the right agent. `editMatcher`
|
|
261
|
+
// differs per agent (Codex edits are apply_patch).
|
|
262
|
+
function addHooks(s, source, editMatcher) {
|
|
263
|
+
s.hooks ||= {};
|
|
264
|
+
const hookCmd = (arg) => `node "${join(CC_DIR, "hook.mjs")}" ${arg} ${source}`;
|
|
265
|
+
const push = (event, matcher, arg) => {
|
|
266
|
+
s.hooks[event] ||= [];
|
|
267
|
+
const entry = { hooks: [{ type: "command", command: hookCmd(arg) }] };
|
|
268
|
+
if (matcher) entry.matcher = matcher;
|
|
269
|
+
s.hooks[event].push(entry);
|
|
270
|
+
};
|
|
271
|
+
push("UserPromptSubmit", null, "prompt");
|
|
272
|
+
push("PostToolUse", editMatcher, "edit");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Merge our hooks (and statusline) into ~/.claude/settings.json, non-destructively.
|
|
276
|
+
function wireClaude() {
|
|
277
|
+
let s = {};
|
|
278
|
+
if (existsSync(CLAUDE_SETTINGS)) {
|
|
279
|
+
try { s = JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf8")); }
|
|
280
|
+
catch { throw new Error(`Could not parse ${CLAUDE_SETTINGS}. Fix or move it, then re-run.`); }
|
|
281
|
+
} else {
|
|
282
|
+
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
|
|
283
|
+
}
|
|
284
|
+
purgeHooks(s);
|
|
285
|
+
addHooks(s, "claude", "Edit|Write|MultiEdit");
|
|
286
|
+
|
|
287
|
+
// Statusline: preserve any existing one by wrapping it.
|
|
288
|
+
const ourStatus = `node "${join(CC_DIR, "statusline.mjs")}"`;
|
|
289
|
+
let wrapped = null;
|
|
290
|
+
const existing = s.statusLine;
|
|
291
|
+
if (existing && existing.command && !existing.command.includes("statusline.mjs")) {
|
|
292
|
+
wrapped = existing.command; // first install: capture the user's real statusline
|
|
293
|
+
} else if (existing && existing.command) {
|
|
294
|
+
// Re-run: our statusline is already installed. Don't treat it as the
|
|
295
|
+
// user's original — keep whatever original we captured the first time.
|
|
296
|
+
wrapped = loadConfig()?.wrappedStatusLine || null;
|
|
297
|
+
}
|
|
298
|
+
s.statusLine = { type: "command", command: ourStatus };
|
|
299
|
+
return { settings: s, wrapped };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Merge our hooks into ~/.codex/hooks.json (same schema as Claude's, JSON so no
|
|
303
|
+
// TOML editing). Codex edits go through apply_patch. No statusline concept.
|
|
304
|
+
function wireCodex() {
|
|
305
|
+
let s = {};
|
|
306
|
+
if (existsSync(CODEX_HOOKS)) {
|
|
307
|
+
try { s = JSON.parse(readFileSync(CODEX_HOOKS, "utf8")); }
|
|
308
|
+
catch { throw new Error(`Could not parse ${CODEX_HOOKS}. Fix or move it, then re-run.`); }
|
|
309
|
+
} else {
|
|
310
|
+
mkdirSync(dirname(CODEX_HOOKS), { recursive: true });
|
|
311
|
+
}
|
|
312
|
+
purgeHooks(s);
|
|
313
|
+
addHooks(s, "codex", "apply_patch|Edit|Write");
|
|
314
|
+
return s;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function finishInstall(cfg) {
|
|
318
|
+
installScripts();
|
|
319
|
+
let wrapped = cfg.wrappedStatusLine ?? null;
|
|
320
|
+
if (wantClaude) {
|
|
321
|
+
const res = wireClaude();
|
|
322
|
+
wrapped = res.wrapped ?? wrapped;
|
|
323
|
+
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
|
|
324
|
+
writeFileSync(CLAUDE_SETTINGS, JSON.stringify(res.settings, null, 2));
|
|
325
|
+
}
|
|
326
|
+
if (wantCodex) {
|
|
327
|
+
const cs = wireCodex();
|
|
328
|
+
mkdirSync(dirname(CODEX_HOOKS), { recursive: true });
|
|
329
|
+
writeFileSync(CODEX_HOOKS, JSON.stringify(cs, null, 2));
|
|
330
|
+
}
|
|
331
|
+
saveConfig({ ...cfg, agent: AGENT, wrappedStatusLine: wrapped });
|
|
332
|
+
await recordVersion(); // stamp what's installed for the daily auto-update check
|
|
333
|
+
return wrapped;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- backfill --------------------------------------------------------------
|
|
337
|
+
// One-time import of the last 7 days of local Claude Code history, so a brand
|
|
338
|
+
// new user doesn't debut at the bottom of the board. The scan happens HERE and
|
|
339
|
+
// sends per-day counts only — never your code. Server rules keep it honest:
|
|
340
|
+
// once per GitHub account ever, only days with zero tracked events (so it can
|
|
341
|
+
// never double-count), and the normal daily caps still apply.
|
|
342
|
+
async function offerBackfill(cfg, { explicit = false } = {}) {
|
|
343
|
+
let scan;
|
|
344
|
+
try { scan = await scanHistory(7); } catch { return; }
|
|
345
|
+
if (!scan.days.length) {
|
|
346
|
+
if (explicit) console.log(` No Claude Code history found for the last 7 days — nothing to backfill.`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
console.log(`\n Found ${c.b(scan.prompts)} prompts and ${c.b(scan.edits)} file edits in your local`);
|
|
350
|
+
console.log(` coding-agent history from the last 7 days (before today). Backfilling`);
|
|
351
|
+
console.log(` sends those per-day counts only — ${c.b("never your code")}.`);
|
|
352
|
+
if (!(await askYesNo("Backfill them onto the board? (one shot per account)"))) {
|
|
353
|
+
console.log(c.dim(` Skipped. Changed your mind? "ccrank backfill" — still one shot.`));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const res = await api("/api/backfill", "POST", { token: cfg.token, days: scan.days });
|
|
358
|
+
if (res.prompts + res.edits > 0) {
|
|
359
|
+
console.log(` ${c.g("✓")} Backfilled ${c.b(res.prompts)} prompts + ${c.b(res.edits)} edits across ${res.days} day${res.days === 1 ? "" : "s"}. No fresh start for you.`);
|
|
360
|
+
} else {
|
|
361
|
+
console.log(c.dim(` Nothing credited — those days already have live-tracked events.`));
|
|
362
|
+
}
|
|
363
|
+
} catch (e) {
|
|
364
|
+
if (e.message === "already_backfilled") {
|
|
365
|
+
console.log(c.dim(` This account already used its one backfill — skipping.`));
|
|
366
|
+
} else {
|
|
367
|
+
console.log(c.dim(` Backfill didn't go through (${e.message}). Retry later: ccrank backfill`));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function backfillCmd() {
|
|
373
|
+
const cfg = loadConfig();
|
|
374
|
+
if (!cfg?.token) return fail(`Not signed in yet. Run "ccrank login" first.`);
|
|
375
|
+
await offerBackfill(cfg, { explicit: true });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---- commands ------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
// Sign in with GitHub and get on the GLOBAL board — no room needed. Also the
|
|
381
|
+
// "new machine" path: GitHub itself is the recovery, so just log in again.
|
|
382
|
+
async function login() {
|
|
383
|
+
const cfg = await ensureUser({ force: true });
|
|
384
|
+
if (!cfg) return;
|
|
385
|
+
const wrapped = await finishInstall(cfg);
|
|
386
|
+
console.log(`\n ${c.g("✓")} Signed in as ${c.y(cfg.login)} ${c.dim("(verified by GitHub)")}`);
|
|
387
|
+
console.log(` You're on the global board. Every prompt you send and every file`);
|
|
388
|
+
console.log(` Claude edits scores you a point. Only counts leave your machine, ${c.b("never your code")}.`);
|
|
389
|
+
// Send them straight to their board — room if they have one, else global.
|
|
390
|
+
// The ?me= link is always printed too (SSH/headless can't pop a browser).
|
|
391
|
+
await offerBackfill(cfg);
|
|
392
|
+
const boardUrl = withMe(cfg.roomCode ? cfg.server + "/r/" + cfg.roomCode : cfg.server + "/", cfg);
|
|
393
|
+
const opened = openBrowser(boardUrl);
|
|
394
|
+
console.log(`\n Your board${opened ? " (opening in your browser)" : ""}:`);
|
|
395
|
+
console.log(` ${c.y(boardUrl)}`);
|
|
396
|
+
if (!cfg.roomCode) console.log(`\n Want a private room for your crew? ${c.dim("ccrank create --name \"Room\" · ccrank join <CODE>")}`);
|
|
397
|
+
if (wrapped) console.log(c.dim(` (kept your existing statusline; rank is appended to it)`));
|
|
398
|
+
console.log(restartMsg());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function create() {
|
|
402
|
+
const name = flags.name || pos[0] || "My room";
|
|
403
|
+
const cfg = await ensureUser();
|
|
404
|
+
if (!cfg) return;
|
|
405
|
+
let created;
|
|
406
|
+
try {
|
|
407
|
+
created = await api("/api/rooms", "POST", { token: cfg.token, name });
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (e.message === "room_name_taken") {
|
|
410
|
+
console.error(`\n A room named ${c.y(name)} already exists. Room names must be unique.`);
|
|
411
|
+
console.error(` Pick a different name and try again.\n`);
|
|
412
|
+
process.exitCode = 1;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
throw e;
|
|
416
|
+
}
|
|
417
|
+
const { code } = created;
|
|
418
|
+
// Creating auto-joins you, so wire everything up right away.
|
|
419
|
+
const wrapped = await finishInstall({ ...cfg, roomCode: code, roomName: name });
|
|
420
|
+
|
|
421
|
+
console.log(`\n ${c.g("✓")} Signed in as ${c.y(cfg.login)} ${c.dim("(verified by GitHub)")}`);
|
|
422
|
+
console.log(` ${c.g("✓")} Room created: ${c.b(name)}. You're in it.`);
|
|
423
|
+
await offerBackfill(cfg);
|
|
424
|
+
// Auto-open the room page: shows the board AND teaches this browser the
|
|
425
|
+
// room code so the site's sidebar can link it from now on.
|
|
426
|
+
const roomUrl = withMe(cfg.server + "/r/" + code, cfg);
|
|
427
|
+
const opened = openBrowser(roomUrl);
|
|
428
|
+
console.log(` Code: ${c.y(code)}`);
|
|
429
|
+
console.log(` Dashboard${opened ? " (opening)" : ""}: ${c.dim(roomUrl)}`);
|
|
430
|
+
console.log(`\n Invite friends. Each of them runs:`);
|
|
431
|
+
console.log(` ${c.b(`npx mostcracked join ${code}`)}\n`);
|
|
432
|
+
if (wrapped) console.log(c.dim(` (kept your existing statusline; rank is appended to it)`));
|
|
433
|
+
console.log(restartMsg());
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function joinRoom() {
|
|
437
|
+
const code = (pos[0] || flags.code || "").toUpperCase();
|
|
438
|
+
if (!code) return fail("Usage: ccrank join <ROOM_CODE>");
|
|
439
|
+
|
|
440
|
+
const cfg = await ensureUser();
|
|
441
|
+
if (!cfg) return;
|
|
442
|
+
let res;
|
|
443
|
+
try {
|
|
444
|
+
res = await api(`/api/rooms/${code}/join`, "POST", { token: cfg.token });
|
|
445
|
+
} catch (e) {
|
|
446
|
+
if (e.message === "invalid token") {
|
|
447
|
+
// Saved session no longer valid (server reset / signed in elsewhere) —
|
|
448
|
+
// wipe it; GitHub is the recovery, so signing in again fixes everything.
|
|
449
|
+
saveConfig({ ...cfg, token: null, login: null });
|
|
450
|
+
return fail(`Your saved session expired. Run "ccrank login" (or this command again) to sign back in with GitHub.`);
|
|
451
|
+
}
|
|
452
|
+
if (e.message === "room not found") return fail(`No room answers to ${code}. Double-check the code.`);
|
|
453
|
+
throw e;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const wrapped = await finishInstall({ ...cfg, roomCode: res.roomCode, roomName: res.roomName });
|
|
457
|
+
|
|
458
|
+
console.log(`\n ${c.g("✓")} Signed in as ${c.y(cfg.login)} ${c.dim("(verified by GitHub)")}`);
|
|
459
|
+
console.log(` ${c.g("✓")} Joined ${c.b(res.roomName)}`);
|
|
460
|
+
await offerBackfill(cfg);
|
|
461
|
+
const who = res.owner ? `${c.b(res.owner)} invited you to` : `You're in`;
|
|
462
|
+
console.log(` ${who} a private room on the global ccrank board.`);
|
|
463
|
+
console.log(` Every prompt you send and every file Claude edits scores you a point.`);
|
|
464
|
+
console.log(` One global score that follows you into every room. Only counts leave`);
|
|
465
|
+
console.log(` your machine, ${c.b("never your code")}.`);
|
|
466
|
+
// Auto-open the room page: shows the board AND teaches this browser the
|
|
467
|
+
// room code so the site's sidebar can link it from now on.
|
|
468
|
+
const roomUrl = withMe(cfg.server + "/r/" + code, cfg);
|
|
469
|
+
const opened = openBrowser(roomUrl);
|
|
470
|
+
console.log(`\n Room board${opened ? " (opening)" : ""}: ${c.y(roomUrl)}`);
|
|
471
|
+
console.log(` Global board: ${c.dim(withMe(cfg.server + "/", cfg))}`);
|
|
472
|
+
console.log(` What is ccrank? ${c.dim("https://mostcracked.com")}`);
|
|
473
|
+
if (wrapped) console.log(c.dim(` (kept your existing statusline; rank is appended to it)`));
|
|
474
|
+
console.log(restartMsg());
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Pull the latest hook/statusline scripts — no re-auth, same user, counts untouched.
|
|
478
|
+
async function update() {
|
|
479
|
+
const cfg = loadConfig();
|
|
480
|
+
if (!cfg?.token) return fail("Not signed in yet. Run: ccrank join <CODE>");
|
|
481
|
+
await finishInstall(cfg);
|
|
482
|
+
console.log(`\n ${c.g("✓")} Updated to the latest ccrank scripts.`);
|
|
483
|
+
// Existing users get their shot at the one-time backfill here — update is
|
|
484
|
+
// the path they already run, and the server rules make it safe (only days
|
|
485
|
+
// with zero tracked events can credit, so it can never double-count).
|
|
486
|
+
await offerBackfill(cfg);
|
|
487
|
+
console.log(restartMsg());
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function status() {
|
|
491
|
+
const cfg = loadConfig();
|
|
492
|
+
if (!cfg?.token) return console.log("Not signed in yet. Run: ccrank join <CODE>");
|
|
493
|
+
try {
|
|
494
|
+
const me = await api(`/api/me?token=${encodeURIComponent(cfg.token)}`);
|
|
495
|
+
console.log(`\n ${c.y(me.login)} ${c.dim("·")} global rank ${c.b("#" + me.rank + "/" + me.total)}, score ${c.g(me.score)}`);
|
|
496
|
+
for (const r of me.rooms || []) {
|
|
497
|
+
console.log(` ${c.dim("room")} ${c.b(r.name)} ${c.dim("· " + cfg.server + "/r/" + r.code)}`);
|
|
498
|
+
}
|
|
499
|
+
console.log(` ${c.dim(cfg.server + "/")}\n`);
|
|
500
|
+
} catch (e) { fail(e.message); }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function leave() {
|
|
504
|
+
// Remove our hooks from BOTH agents + restore any wrapped statusline. Leaves
|
|
505
|
+
// config in place. Idempotent — safe to run whatever was installed.
|
|
506
|
+
const cfg = loadConfig();
|
|
507
|
+
if (existsSync(CLAUDE_SETTINGS)) {
|
|
508
|
+
const s = JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf8"));
|
|
509
|
+
purgeHooks(s);
|
|
510
|
+
if (s.statusLine?.command?.includes("statusline.mjs")) {
|
|
511
|
+
if (cfg?.wrappedStatusLine) s.statusLine = { type: "command", command: cfg.wrappedStatusLine };
|
|
512
|
+
else delete s.statusLine;
|
|
513
|
+
}
|
|
514
|
+
writeFileSync(CLAUDE_SETTINGS, JSON.stringify(s, null, 2));
|
|
515
|
+
}
|
|
516
|
+
if (existsSync(CODEX_HOOKS)) {
|
|
517
|
+
const s = JSON.parse(readFileSync(CODEX_HOOKS, "utf8"));
|
|
518
|
+
purgeHooks(s);
|
|
519
|
+
writeFileSync(CODEX_HOOKS, JSON.stringify(s, null, 2));
|
|
520
|
+
}
|
|
521
|
+
console.log(` ${c.g("✓")} Removed ccrank hooks. Restart your agent to finish.`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function fail(msg) { console.error(` ${msg}`); process.exitCode = 1; }
|
|
525
|
+
|
|
526
|
+
function help() {
|
|
527
|
+
console.log(`
|
|
528
|
+
${c.b("ccrank")} is the global leaderboard for Claude Code and Codex. Every
|
|
529
|
+
prompt and edit scores a point. Sign in with GitHub and you're on the board
|
|
530
|
+
with every ccrank user. Rooms are optional private groups viewing the same
|
|
531
|
+
scores. Use both agents? Your scores merge into one; the board marks Codex.
|
|
532
|
+
|
|
533
|
+
${c.y("ccrank login")} sign in with GitHub, get on the global board
|
|
534
|
+
${c.y("ccrank join")} <CODE> join a private room (logs you in if needed)
|
|
535
|
+
${c.y("ccrank create")} --name "Room" create a room, auto-joins you (logs in if needed)
|
|
536
|
+
${c.y("ccrank update")} pull the latest scripts (no re-auth)
|
|
537
|
+
${c.y("ccrank backfill")} one-time import of your last 7 days of local
|
|
538
|
+
Claude Code history (counts only, never code)
|
|
539
|
+
${c.y("ccrank status")} show your global rank + rooms
|
|
540
|
+
${c.y("ccrank leave")} remove the hooks (both agents)
|
|
541
|
+
|
|
542
|
+
Sign-in is GitHub device flow: you enter a one-time code at
|
|
543
|
+
github.com/login/device (or reuse your gh CLI session if it's signed in).
|
|
544
|
+
Scope read:user, public profile only. New machine? Just "ccrank login" again.
|
|
545
|
+
|
|
546
|
+
Options:
|
|
547
|
+
--agent <which> which agent to wire: claude (default), codex, or both.
|
|
548
|
+
Claude hooks -> ~/.claude/settings.json; Codex hooks ->
|
|
549
|
+
~/.codex/hooks.json.
|
|
550
|
+
--server <url> point at a different ccrank server (or set CCRANK_SERVER)
|
|
551
|
+
`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const table = { login, create, join: joinRoom, update, status, leave, backfill: backfillCmd, help };
|
|
555
|
+
Promise.resolve((table[cmd] || help)()).catch((e) => fail(e.message));
|
package/lib/backfill.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// ccrank backfill — one-time import of the last 7 days of LOCAL Claude Code
|
|
2
|
+
// history, so a brand-new user doesn't debut at the bottom of the board.
|
|
3
|
+
// Reads the transcripts Claude Code itself writes (~/.claude/projects/**/*.jsonl)
|
|
4
|
+
// and counts prompts + edits the same way the live hook would have. Only
|
|
5
|
+
// per-day COUNTS are produced — message content never leaves this module.
|
|
6
|
+
//
|
|
7
|
+
// Today (UTC) is excluded: the live hooks already count it, and the server
|
|
8
|
+
// additionally refuses any day the user has tracked events on.
|
|
9
|
+
|
|
10
|
+
import { readdirSync, statSync, createReadStream } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
|
|
15
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
|
|
16
|
+
|
|
17
|
+
// Same line-counting rules as lib/hook.mjs editValue() — a number only.
|
|
18
|
+
function editLines(name, inp) {
|
|
19
|
+
let text = "";
|
|
20
|
+
if (name === "Write") text = inp?.content || "";
|
|
21
|
+
else if (name === "Edit") text = inp?.new_string || "";
|
|
22
|
+
else if (name === "MultiEdit") text = (inp?.edits || []).map((e) => e?.new_string || "").join("\n");
|
|
23
|
+
const lines = String(text).split("\n").filter((l) => l.trim().length).length;
|
|
24
|
+
return Math.max(1, lines);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A transcript "user" entry that the UserPromptSubmit hook would have fired
|
|
28
|
+
// for: a real typed prompt — not tool results, not meta/compact bookkeeping,
|
|
29
|
+
// not a subagent's inner conversation.
|
|
30
|
+
function isRealPrompt(e) {
|
|
31
|
+
if (e.type !== "user" || e.isMeta || e.isSidechain || e.isCompactSummary) return false;
|
|
32
|
+
if (e.userType && e.userType !== "external") return false;
|
|
33
|
+
const content = e.message?.content;
|
|
34
|
+
if (typeof content === "string") {
|
|
35
|
+
return content.trim().length > 0 && !content.startsWith("<local-command-stdout>");
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(content)) {
|
|
38
|
+
if (content.some((b) => b?.type === "tool_result")) return false;
|
|
39
|
+
return content.some((b) => b?.type === "text" && String(b.text || "").trim().length);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function* jsonlFiles(dir) {
|
|
45
|
+
let entries;
|
|
46
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
47
|
+
for (const ent of entries) {
|
|
48
|
+
const p = join(dir, ent.name);
|
|
49
|
+
if (ent.isDirectory()) yield* jsonlFiles(p);
|
|
50
|
+
else if (ent.name.endsWith(".jsonl")) yield p;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scanFile(path, start, end, byDay) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
57
|
+
rl.on("line", (line) => {
|
|
58
|
+
let e;
|
|
59
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
60
|
+
const ts = Date.parse(e?.timestamp || "");
|
|
61
|
+
if (!Number.isFinite(ts) || ts < start || ts >= end) return;
|
|
62
|
+
const day = new Date(ts).toISOString().slice(0, 10);
|
|
63
|
+
let d = byDay.get(day);
|
|
64
|
+
if (!d) { d = { day, prompts: 0, edits: 0, lines: 0 }; byDay.set(day, d); }
|
|
65
|
+
if (isRealPrompt(e)) { d.prompts++; return; }
|
|
66
|
+
// Edits: PostToolUse fires inside subagents too, so no isSidechain filter.
|
|
67
|
+
if (e?.type === "assistant" && Array.isArray(e.message?.content)) {
|
|
68
|
+
for (const b of e.message.content) {
|
|
69
|
+
if (b?.type === "tool_use" && EDIT_TOOLS.has(b.name)) {
|
|
70
|
+
d.edits++;
|
|
71
|
+
d.lines += editLines(b.name, b.input || {});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
rl.on("close", resolve);
|
|
77
|
+
rl.on("error", resolve);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- Codex (best-effort) ---------------------------------------------------
|
|
82
|
+
// Codex CLI writes rollout files (~/.codex/sessions/**/*.jsonl). The format is
|
|
83
|
+
// Codex-internal and less stable than Claude's, so this parser is deliberately
|
|
84
|
+
// conservative: shapes it doesn't recognize count 0, never garbage. Prompts =
|
|
85
|
+
// durable response_item user messages with real typed text (Codex wraps its
|
|
86
|
+
// injected user_instructions / environment_context in <tags>, so a leading
|
|
87
|
+
// '<' is filtered out). Edits = apply_patch calls, lines = added '+' lines.
|
|
88
|
+
function codexPromptOrEdit(e, d) {
|
|
89
|
+
const p = e?.payload;
|
|
90
|
+
if (e?.type !== "response_item" || !p) return;
|
|
91
|
+
if (p.type === "message" && p.role === "user") {
|
|
92
|
+
const texts = Array.isArray(p.content)
|
|
93
|
+
? p.content.filter((b) => b?.type === "input_text").map((b) => String(b.text || ""))
|
|
94
|
+
: [];
|
|
95
|
+
const t = texts.join("").trim();
|
|
96
|
+
if (t.length && !t.startsWith("<")) d.prompts++;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
let patch = null;
|
|
100
|
+
if (p.type === "function_call") {
|
|
101
|
+
const name = String(p.name || "");
|
|
102
|
+
const args = String(p.arguments || "");
|
|
103
|
+
if (name === "apply_patch" || (name === "shell" && args.includes("apply_patch"))) patch = args;
|
|
104
|
+
} else if (p.type === "custom_tool_call" && String(p.name || "") === "apply_patch") {
|
|
105
|
+
patch = String(p.input || "");
|
|
106
|
+
}
|
|
107
|
+
if (patch != null) {
|
|
108
|
+
const added = patch.split("\n")
|
|
109
|
+
.filter((l) => l.startsWith("+") && !l.startsWith("+++") && l.slice(1).trim().length).length;
|
|
110
|
+
d.edits++;
|
|
111
|
+
d.lines += Math.max(1, added);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scanCodexFile(path, start, end, byDay) {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
118
|
+
rl.on("line", (line) => {
|
|
119
|
+
let e;
|
|
120
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
121
|
+
const ts = Date.parse(e?.timestamp || "");
|
|
122
|
+
if (!Number.isFinite(ts) || ts < start || ts >= end) return;
|
|
123
|
+
const day = new Date(ts).toISOString().slice(0, 10);
|
|
124
|
+
let d = byDay.get(day);
|
|
125
|
+
if (!d) { d = { day, prompts: 0, edits: 0, lines: 0 }; byDay.set(day, d); }
|
|
126
|
+
codexPromptOrEdit(e, d);
|
|
127
|
+
});
|
|
128
|
+
rl.on("close", resolve);
|
|
129
|
+
rl.on("error", resolve);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function scanRoot(root, start, end, byDay, scanOne) {
|
|
134
|
+
for (const file of jsonlFiles(root)) {
|
|
135
|
+
// A file last touched before the window started can't hold in-window entries.
|
|
136
|
+
try { if (statSync(file).mtimeMs < start) continue; } catch { continue; }
|
|
137
|
+
await scanOne(file, start, end, byDay);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Scan the last `daysBack` full UTC days (today excluded) of BOTH agents'
|
|
142
|
+
// local history — history is history, whatever agent it came from. Returns
|
|
143
|
+
// { days: [{day, prompts, edits, lines}], prompts, edits, lines } — sorted by
|
|
144
|
+
// day, zero-activity days omitted.
|
|
145
|
+
export async function scanHistory(daysBack = 7) {
|
|
146
|
+
const end = Date.parse(new Date().toISOString().slice(0, 10) + "T00:00:00Z"); // start of today UTC
|
|
147
|
+
const start = end - daysBack * 86400000;
|
|
148
|
+
|
|
149
|
+
const byDay = new Map();
|
|
150
|
+
await scanRoot(join(homedir(), ".claude", "projects"), start, end, byDay, scanFile);
|
|
151
|
+
await scanRoot(join(homedir(), ".codex", "sessions"), start, end, byDay, scanCodexFile);
|
|
152
|
+
|
|
153
|
+
const days = [...byDay.values()]
|
|
154
|
+
.filter((d) => d.prompts + d.edits > 0)
|
|
155
|
+
.sort((a, b) => (a.day < b.day ? -1 : 1));
|
|
156
|
+
const sum = (k) => days.reduce((s, d) => s + d[k], 0);
|
|
157
|
+
return { days, prompts: sum("prompts"), edits: sum("edits"), lines: sum("lines") };
|
|
158
|
+
}
|
package/lib/hook.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ccrank hook — runs on Claude Code or Codex UserPromptSubmit / PostToolUse.
|
|
2
|
+
// Sends ONLY counts + metadata to the server. Never sends code content.
|
|
3
|
+
// Must stay silent on stdout (UserPromptSubmit stdout is injected into context).
|
|
4
|
+
//
|
|
5
|
+
// argv[2] = kind ('prompt' | 'edit'); argv[3] = source ('claude' | 'codex').
|
|
6
|
+
// The installer bakes the source in per agent, so one hook file serves both.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { maybeAutoUpdate } from "./update-check.mjs";
|
|
12
|
+
|
|
13
|
+
const kind = process.argv[2] === "edit" ? "edit" : "prompt";
|
|
14
|
+
const source = process.argv[3] === "codex" ? "codex" : "claude";
|
|
15
|
+
|
|
16
|
+
function config() {
|
|
17
|
+
try { return JSON.parse(readFileSync(join(homedir(), ".ccrank", "config.json"), "utf8")); }
|
|
18
|
+
catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readStdin() {
|
|
22
|
+
try { return readFileSync(0, "utf8"); } catch { return ""; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Count lines touched by an edit — a number only, no content ever leaves the
|
|
26
|
+
// machine. Claude Code tools carry the new text directly; Codex's apply_patch
|
|
27
|
+
// carries a unified-diff-ish patch, so we count its added ('+') lines.
|
|
28
|
+
function editValue(payload) {
|
|
29
|
+
try {
|
|
30
|
+
const t = payload.tool_name || "";
|
|
31
|
+
const inp = payload.tool_input || {};
|
|
32
|
+
// Codex applies edits as a patch (apply_patch). Count added lines.
|
|
33
|
+
if (t === "apply_patch" || (source === "codex" && !inp.content && !inp.new_string)) {
|
|
34
|
+
const patch = String(inp.input || inp.patch || inp.content || inp.changes || "");
|
|
35
|
+
const added = patch.split("\n").filter(
|
|
36
|
+
(l) => l.startsWith("+") && !l.startsWith("+++") && l.slice(1).trim().length).length;
|
|
37
|
+
if (added) return added;
|
|
38
|
+
// Not a recognizable patch — fall back to non-empty line count.
|
|
39
|
+
const any = patch.split("\n").filter((l) => l.trim().length).length;
|
|
40
|
+
return Math.max(1, any);
|
|
41
|
+
}
|
|
42
|
+
let text = "";
|
|
43
|
+
if (t === "Write") text = inp.content || "";
|
|
44
|
+
else if (t === "Edit") text = inp.new_string || "";
|
|
45
|
+
else if (t === "MultiEdit") text = (inp.edits || []).map((e) => e.new_string || "").join("\n");
|
|
46
|
+
const lines = text.split("\n").filter((l) => l.trim().length).length;
|
|
47
|
+
return Math.max(1, lines);
|
|
48
|
+
} catch { return 1; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
const cfg = config();
|
|
53
|
+
if (!cfg?.token || !cfg?.server) return; // not set up; do nothing
|
|
54
|
+
|
|
55
|
+
let payload = {};
|
|
56
|
+
try { payload = JSON.parse(readStdin() || "{}"); } catch {}
|
|
57
|
+
|
|
58
|
+
const value = kind === "edit" ? editValue(payload) : 1;
|
|
59
|
+
|
|
60
|
+
const ctrl = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
62
|
+
try {
|
|
63
|
+
await fetch(cfg.server.replace(/\/$/, "") + "/api/events", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "content-type": "application/json" },
|
|
66
|
+
body: JSON.stringify({ token: cfg.token, kind, value, source }),
|
|
67
|
+
signal: ctrl.signal,
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
/* offline / slow — never block Claude Code */
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
// Daily no-op except when a new client version exists (see update-check.mjs).
|
|
75
|
+
// Covers Codex-only users, who have no statusline. Stays silent on stdout.
|
|
76
|
+
await maybeAutoUpdate(cfg.server);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
main().finally(() => process.exit(0));
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// ccrank statusline — prints your live GLOBAL rank, e.g. CC-Rank #2/14 · leaderboard ↗
|
|
2
|
+
// The "leaderboard" text links to the GLOBAL board only — never room URLs or
|
|
3
|
+
// personal params; this line lives on screen through screenshares.
|
|
4
|
+
// If you already had a statusline, it runs that first and appends this,
|
|
5
|
+
// so nothing you had is lost.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { maybeAutoUpdate } from "./update-check.mjs";
|
|
12
|
+
|
|
13
|
+
const CC = join(homedir(), ".ccrank");
|
|
14
|
+
const CACHE = join(CC, "cache.json");
|
|
15
|
+
// Last successful render of the wrapped (user's original) statusline. We reuse
|
|
16
|
+
// it if the wrapped command times out or fails on a given tick, so a slow start
|
|
17
|
+
// never blanks out someone else's statusline — we must not make their line
|
|
18
|
+
// flicker in and out just because ours runs it. See wrapped() below.
|
|
19
|
+
const WRAP_CACHE = join(CC, "wrapped.json");
|
|
20
|
+
// How long a wrapped statusline is spawned to run before we give up on it and
|
|
21
|
+
// fall back to WRAP_CACHE. Node-based statuslines (ccstatusline, ccusage, …)
|
|
22
|
+
// cold-start anywhere from ~0.3s to ~1.7s, so this must comfortably clear that.
|
|
23
|
+
const WRAP_TIMEOUT_MS = 3000;
|
|
24
|
+
// Don't reuse a wrapped render older than this — better a missing line than a
|
|
25
|
+
// wildly stale model/cost readout if their statusline was removed or broke.
|
|
26
|
+
const WRAP_MAX_AGE_MS = 5 * 60 * 1000;
|
|
27
|
+
// Only hit the network every CACHE_TTL_MS; serve cache in between. This is the
|
|
28
|
+
// single biggest lever on server/D1 usage — the statusline re-renders far more
|
|
29
|
+
// often than this, so without it we'd query the DB on every keystroke-ish tick.
|
|
30
|
+
const CACHE_TTL_MS = 15 * 1000;
|
|
31
|
+
// If the data we're showing is older than this (e.g. we're offline and serving
|
|
32
|
+
// old cache), flag it so nobody trusts a stale rank as live.
|
|
33
|
+
const STALE_AFTER_MS = 5 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
function config() {
|
|
36
|
+
try { return JSON.parse(readFileSync(join(CC, "config.json"), "utf8")); }
|
|
37
|
+
catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
function readStdin() { try { return readFileSync(0, "utf8"); } catch { return ""; } }
|
|
40
|
+
function readCache() { try { return JSON.parse(readFileSync(CACHE, "utf8")); } catch { return null; } }
|
|
41
|
+
function writeCache(obj) { try { writeFileSync(CACHE, JSON.stringify({ ...obj, _ts: Date.now() })); } catch {} }
|
|
42
|
+
function readWrapCache() { try { return JSON.parse(readFileSync(WRAP_CACHE, "utf8")); } catch { return null; } }
|
|
43
|
+
function writeWrapCache(out) { try { writeFileSync(WRAP_CACHE, JSON.stringify({ out, _ts: Date.now() })); } catch {} }
|
|
44
|
+
|
|
45
|
+
// Run the user's previous statusline command, feeding it the same stdin.
|
|
46
|
+
// Resolves to its stdout on a clean exit, or "" if it times out / fails to
|
|
47
|
+
// spawn. On timeout we discard any partial output — a half-written line from a
|
|
48
|
+
// killed process is unreliable; the caller falls back to the last good render.
|
|
49
|
+
function runWrapped(cmd, input) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (!cmd) return resolve("");
|
|
52
|
+
let out = "";
|
|
53
|
+
const child = spawn(cmd, { shell: true });
|
|
54
|
+
const kill = setTimeout(() => { try { child.kill(); } catch {} resolve(""); }, WRAP_TIMEOUT_MS);
|
|
55
|
+
child.stdout.on("data", (d) => (out += d));
|
|
56
|
+
child.on("close", () => { clearTimeout(kill); resolve(out.trim()); });
|
|
57
|
+
child.on("error", () => { clearTimeout(kill); resolve(""); });
|
|
58
|
+
// A fast-exiting child may close stdin before we finish writing — swallow EPIPE.
|
|
59
|
+
child.stdin.on("error", () => {});
|
|
60
|
+
try { child.stdin.write(input); child.stdin.end(); } catch {}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Run the wrapped statusline, but never blank it out on a slow/failed tick:
|
|
65
|
+
// cache each good render and reuse the last fresh one when this run comes back
|
|
66
|
+
// empty. Keeps the user's original statusline rock-steady alongside ours.
|
|
67
|
+
async function wrapped(cmd, input) {
|
|
68
|
+
if (!cmd) return "";
|
|
69
|
+
const out = await runWrapped(cmd, input);
|
|
70
|
+
if (out) { writeWrapCache(out); return out; }
|
|
71
|
+
const c = readWrapCache();
|
|
72
|
+
if (c && c.out && Date.now() - (c._ts || 0) < WRAP_MAX_AGE_MS) return c.out;
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Wrap text in an OSC 8 terminal hyperlink. Terminals that support it (iTerm2,
|
|
77
|
+
// WezTerm, Kitty, recent VS Code / GNOME Terminal) make it click / ⌘-click to
|
|
78
|
+
// open; ones that don't just show the plain text.
|
|
79
|
+
function link(url, text) {
|
|
80
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Rank colors: #1 gold, #2 silver, #3 bronze, everyone else bright cyan.
|
|
84
|
+
function rankColor(rank) {
|
|
85
|
+
if (rank === 1) return "\x1b[1;38;5;220m";
|
|
86
|
+
if (rank === 2) return "\x1b[1;38;5;252m";
|
|
87
|
+
if (rank === 3) return "\x1b[1;38;5;208m";
|
|
88
|
+
return "\x1b[1;96m";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function rankSegment(cfg) {
|
|
92
|
+
const base = cfg.server.replace(/\/$/, "");
|
|
93
|
+
// ALWAYS the global board. Never the room URL: room codes are join
|
|
94
|
+
// credentials, and the statusline sits on screen through every screenshare
|
|
95
|
+
// and recording — printing /r/CODE here would leak room access to anyone
|
|
96
|
+
// watching. (Same reason there's no ?me= — nothing personal in this line.)
|
|
97
|
+
// Your rooms are one click away in the site's sidebar once you're there.
|
|
98
|
+
const boardUrl = base + "/";
|
|
99
|
+
const view = `\x1b[94;4m${link(boardUrl, "leaderboard ↗")}\x1b[0m`;
|
|
100
|
+
const label = "\x1b[1;35mCC-Rank\x1b[0m";
|
|
101
|
+
// One line only. We used to print the raw URL underneath for terminals that
|
|
102
|
+
// don't support OSC-8 links, but it sits directly above the prompt and people
|
|
103
|
+
// kept mis-clicking it into a browser. The "leaderboard ↗" link stays.
|
|
104
|
+
// A dim "· Nm old" suffix when the shown data is older than STALE_AFTER_MS.
|
|
105
|
+
const age = (ts) => {
|
|
106
|
+
const ms = Date.now() - (ts || 0);
|
|
107
|
+
if (ms < STALE_AFTER_MS) return "";
|
|
108
|
+
const m = Math.round(ms / 60000);
|
|
109
|
+
return m < 60 ? ` \x1b[2m· ${m}m old\x1b[0m` : ` \x1b[2m· ${Math.round(m / 60)}h old\x1b[0m`;
|
|
110
|
+
};
|
|
111
|
+
const fmtRank = (me, ts) => `${label} ${rankColor(me.rank)}#${me.rank}/${me.total}\x1b[0m \x1b[2m·\x1b[0m ${view}${age(ts)}`;
|
|
112
|
+
const deleted = () => `\x1b[1;38;5;203msigned out\x1b[0m \x1b[2m·\x1b[0m \x1b[94;4m${link(base, "re-join ccrank ↗")}\x1b[0m`;
|
|
113
|
+
const fromCache = (c) =>
|
|
114
|
+
c?.deleted ? deleted() : c?.rank ? fmtRank(c, c._ts) : null;
|
|
115
|
+
|
|
116
|
+
// Serve a fresh-enough cache without any network call.
|
|
117
|
+
const cached = readCache();
|
|
118
|
+
if (cached && cached.roomCode === cfg.roomCode && cached._ts &&
|
|
119
|
+
Date.now() - cached._ts < CACHE_TTL_MS) {
|
|
120
|
+
const line = fromCache(cached);
|
|
121
|
+
if (line) return line;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Cache stale/missing — refresh from the server (bounded by a timeout).
|
|
125
|
+
const ctrl = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => ctrl.abort(), 2500);
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(base + "/api/me?token=" + encodeURIComponent(cfg.token), { signal: ctrl.signal });
|
|
129
|
+
const me = await res.json();
|
|
130
|
+
if (me && me.rank) {
|
|
131
|
+
writeCache({ ...me, roomCode: cfg.roomCode });
|
|
132
|
+
return fmtRank(me, Date.now()); // just fetched — no age suffix
|
|
133
|
+
}
|
|
134
|
+
// Server answered but doesn't know us — room/player was deleted.
|
|
135
|
+
if (res.status === 401 || res.status === 404) {
|
|
136
|
+
writeCache({ deleted: true, roomCode: cfg.roomCode });
|
|
137
|
+
return deleted();
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Offline / slow — fall back to whatever we last cached, even if stale.
|
|
141
|
+
const line = fromCache(cached);
|
|
142
|
+
if (line) return line;
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
// No rank yet (or offline with no cache) — still offer the link.
|
|
147
|
+
return view;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- usage reporting -------------------------------------------------------
|
|
151
|
+
// Claude Code hands the statusline `cost.total_cost_usd` — CUMULATIVE for the
|
|
152
|
+
// session. Post it (with the session id) so the board can show tokens/$;
|
|
153
|
+
// the server keeps MAX per session, so re-posts can never double-count.
|
|
154
|
+
// Throttle: only when this session's cost grew ≥ $0.05 since our last post
|
|
155
|
+
// (state in usage.json — separate file so rank cache writes never clobber it).
|
|
156
|
+
const USAGE_FILE = join(CC, "usage.json");
|
|
157
|
+
async function reportUsage(cfg, input) {
|
|
158
|
+
try {
|
|
159
|
+
const p = JSON.parse(input || "{}");
|
|
160
|
+
const sid = String(p.session_id || "");
|
|
161
|
+
const usd = Math.round(Number(p.cost && p.cost.total_cost_usd) * 100) / 100;
|
|
162
|
+
if (!/^[A-Za-z0-9._-]{8,64}$/.test(sid) || !(usd > 0)) return;
|
|
163
|
+
let last = null;
|
|
164
|
+
try { last = JSON.parse(readFileSync(USAGE_FILE, "utf8")); } catch {}
|
|
165
|
+
if (last && last.sid === sid && usd < (last.usd || 0) + 0.05) return;
|
|
166
|
+
const ctrl = new AbortController();
|
|
167
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
168
|
+
try {
|
|
169
|
+
const res = await fetch(cfg.server.replace(/\/$/, "") + "/api/usage", {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "content-type": "application/json" },
|
|
172
|
+
body: JSON.stringify({ token: cfg.token, session_id: sid, cost_usd: usd }),
|
|
173
|
+
signal: ctrl.signal,
|
|
174
|
+
});
|
|
175
|
+
if (res.ok) writeFileSync(USAGE_FILE, JSON.stringify({ sid, usd }));
|
|
176
|
+
} catch { /* offline — retry naturally on a later tick */ }
|
|
177
|
+
finally { clearTimeout(timer); }
|
|
178
|
+
} catch { /* never let usage reporting break the statusline */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
const input = readStdin();
|
|
183
|
+
const cfg = config();
|
|
184
|
+
const prev = await wrapped(cfg?.wrappedStatusLine, input);
|
|
185
|
+
const [seg] = await Promise.all([
|
|
186
|
+
cfg?.token ? rankSegment(cfg) : "",
|
|
187
|
+
cfg?.token ? reportUsage(cfg, input) : null,
|
|
188
|
+
// Daily no-op except when a new client version exists — then it spawns a
|
|
189
|
+
// detached background self-update (see update-check.mjs). Bounded at 1.5s.
|
|
190
|
+
cfg?.server ? maybeAutoUpdate(cfg.server) : null,
|
|
191
|
+
]);
|
|
192
|
+
process.stdout.write([prev, seg].filter(Boolean).join(" "));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main().finally(() => process.exit(0));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ccrank auto-update — the "never lift a finger" pipe. Once a day, whichever
|
|
2
|
+
// of the hook / statusline ticks first asks the server which version of the
|
|
3
|
+
// client is current; if it isn't the one recorded as applied, it spawns
|
|
4
|
+
// `npx -y mostcracked@<version> update` detached in the background. That
|
|
5
|
+
// re-runs the installer: fresh scripts into ~/.ccrank, hooks rewired, and the
|
|
6
|
+
// one-time backfill offered — all with zero user action.
|
|
7
|
+
//
|
|
8
|
+
// Security shape (the old curl-style self-updater was removed on purpose in
|
|
9
|
+
// the hardening pass; this is its replacement): the package name is
|
|
10
|
+
// HARD-PINNED here, so the server only ever names WHICH version of OUR npm
|
|
11
|
+
// package to run — it can never point users at other code. Install goes
|
|
12
|
+
// through the npm registry, same trust as the original `npx mostcracked`
|
|
13
|
+
// install.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const STATE = join(homedir(), ".ccrank", "update.json");
|
|
21
|
+
const PKG = "mostcracked"; // pinned — never comes from the server
|
|
22
|
+
// Every ~3h. Cheap: the check is one 1.5s-bounded GET, and /api/client-version
|
|
23
|
+
// is cached an hour server-side, so this is ~8 tiny requests per user per day.
|
|
24
|
+
// Actual update spawns stay gated on version !== appliedVersion — they're
|
|
25
|
+
// bounded by how often we publish, not by how often we check.
|
|
26
|
+
const CHECK_EVERY_MS = 3 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
|
|
29
|
+
|
|
30
|
+
function state() {
|
|
31
|
+
try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; }
|
|
32
|
+
}
|
|
33
|
+
function save(patch) {
|
|
34
|
+
try { writeFileSync(STATE, JSON.stringify({ ...state(), ...patch })); } catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Record which version is installed. Written by `ccrank update
|
|
38
|
+
// --applied-version` (exact) and best-effort after any install, so a fresh
|
|
39
|
+
// install at the latest version doesn't trigger a pointless update spawn the
|
|
40
|
+
// next day. Also still accepts the legacy 40-hex sha the pre-npm auto-updater
|
|
41
|
+
// passes via `update --applied-sha` — harmless, keeps the transition quiet.
|
|
42
|
+
export function recordApplied(v) {
|
|
43
|
+
const s = String(v);
|
|
44
|
+
if (SEMVER.test(s)) save({ appliedVersion: s, appliedAt: Date.now() });
|
|
45
|
+
else if (/^[0-9a-f]{7,40}$/.test(s)) save({ appliedSha: s, appliedAt: Date.now() });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function maybeAutoUpdate(server) {
|
|
49
|
+
const st = state();
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
if (!server || now - (st.checkedAt || 0) < CHECK_EVERY_MS) return;
|
|
52
|
+
// Slam the door BEFORE the network call so concurrent hook/statusline ticks
|
|
53
|
+
// (or several open sessions) can't double-run the check.
|
|
54
|
+
save({ checkedAt: now });
|
|
55
|
+
|
|
56
|
+
const ctrl = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(String(server).replace(/\/$/, "") + "/api/client-version", { signal: ctrl.signal });
|
|
60
|
+
const v = String((await res.json())?.version || "");
|
|
61
|
+
if (!SEMVER.test(v) || v === st.appliedVersion) return;
|
|
62
|
+
const child = spawn(
|
|
63
|
+
process.platform === "win32" ? "npx.cmd" : "npx",
|
|
64
|
+
["-y", `${PKG}@${v}`, "update", "--applied-version", v],
|
|
65
|
+
{ detached: true, stdio: "ignore", shell: process.platform === "win32" });
|
|
66
|
+
child.on("error", () => {});
|
|
67
|
+
child.unref();
|
|
68
|
+
} catch {
|
|
69
|
+
/* offline / slow — the next daily window retries */
|
|
70
|
+
} finally { clearTimeout(timer); }
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mostcracked",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The global Claude Code leaderboard — every prompt and every edit scores a point, live at mostcracked.com.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ccrank": "bin/ccrank.mjs",
|
|
8
|
+
"mostcracked": "bin/ccrank.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
}
|