Skip to content

Commit f3707cc

Browse files
lidge-junclaude
andcommitted
feat(cli): interactive [Y/n] GitHub-star prompt that stars via gh (agbrowse-style)
Replace the passive printed star notice with an interactive prompt that, on yes, stars the repo directly through the user's `gh` auth (gh api -X PUT /user/starred/...), matching agbrowse's postinstall UX. - scripts/postinstall.mjs: runs on `npm install -g opencodex` (TTY-only, gh-gated, once). - src/star-prompt.ts: same [Y/n]+gh flow on first interactive `ocx start` — the reliable path for bun-native installs where `bun install -g` may skip postinstall. Both share the ~/.opencodex/.star-prompted marker, so it fires exactly once across install + start. - package.json: add postinstall script + ship scripts/postinstall.mjs via "files". No-op under OCX_SERVICE, non-TTY/CI, when already prompted, or when gh is absent; never blocks install/start. Verified: tsc clean, postinstall no-ops in non-TTY (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ebba2a8 commit f3707cc

4 files changed

Lines changed: 99 additions & 20 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"files": [
1212
"src",
13+
"scripts/postinstall.mjs",
1314
"gui/dist",
1415
"README.md",
1516
"LICENSE"
@@ -22,6 +23,7 @@
2223
"start": "bun run src/cli.ts start",
2324
"typecheck": "bun x tsc --noEmit",
2425
"build:gui": "cd gui && bun install && bun run build",
26+
"postinstall": "node scripts/postinstall.mjs",
2527
"prepublishOnly": "bun run typecheck && bun run build:gui"
2628
},
2729
"dependencies": {

scripts/postinstall.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#!/usr/bin/env node
2+
/**
3+
* postinstall — one-time GitHub-star prompt during `npm install -g opencodex`.
4+
*
5+
* Behavior:
6+
* - TTY-only (skips CI / piped installs)
7+
* - Requires `gh` CLI with auth (stars directly via `gh api`)
8+
* - Prompts once; shares the ~/.opencodex/.star-prompted marker with the first-`ocx start` prompt
9+
* (so bun users — where postinstall may not run on `-g` — still get it exactly once on start)
10+
* - Never blocks the install (all errors silently caught)
11+
*/
12+
import { spawnSync } from "node:child_process";
13+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14+
import { join } from "node:path";
15+
import { createInterface } from "node:readline/promises";
16+
import { homedir } from "node:os";
17+
18+
const REPO = "lidge-jun/opencodex";
19+
const MARKER = join(homedir(), ".opencodex", ".star-prompted");
20+
21+
function ghInstalled() {
22+
const r = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
23+
return !r.error && r.status === 0;
24+
}
25+
26+
function starRepo() {
27+
const r = spawnSync("gh", ["api", "-X", "PUT", `/user/starred/${REPO}`],
28+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true });
29+
if (r.error) return { ok: false, error: r.error.message };
30+
if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || "").trim() || `gh exited ${r.status}` };
31+
return { ok: true };
32+
}
33+
34+
async function askYesNo(question) {
35+
const rl = createInterface({ input: process.stdin, output: process.stdout });
36+
try {
37+
const a = (await rl.question(question)).trim().toLowerCase();
38+
return a === "" || a === "y" || a === "yes";
39+
} finally {
40+
rl.close();
41+
}
42+
}
43+
44+
async function main() {
45+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
46+
if (existsSync(MARKER)) return;
47+
if (!ghInstalled()) return;
48+
49+
try { mkdirSync(join(homedir(), ".opencodex"), { recursive: true }); writeFileSync(MARKER, new Date().toISOString()); } catch { /* best-effort */ }
50+
51+
const yes = await askYesNo("[opencodex] Enjoying opencodex? Star it on GitHub? [Y/n] ");
52+
if (!yes) return;
53+
const r = starRepo();
54+
console.log(r.ok ? "[opencodex] Thanks for the star! ⭐" : `[opencodex] Couldn't star automatically: ${r.error}`);
55+
}
56+
57+
main().catch(() => { /* never fail the install */ });

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ function handleStart() {
6666
const server = startServer(port);
6767
writePid(process.pid);
6868

69-
maybeShowStarPrompt(); // once-only GitHub-star request on first interactive start
69+
void maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
7070
syncModelsToCodex(port).catch(() => {});
7171

7272
const shutdown = () => {

src/star-prompt.ts

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
11
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
3+
import { spawnSync } from "node:child_process";
4+
import { createInterface } from "node:readline/promises";
35
import { getConfigDir } from "./config";
46

5-
const REPO_URL = "https://github.com/lidge-jun/opencodex";
6-
/** Marker so the prompt is shown exactly once per install. */
7+
const REPO = "lidge-jun/opencodex";
8+
/** Shared with scripts/postinstall.mjs so the prompt fires exactly once across install + first start. */
79
const MARKER = ".star-prompted";
810

11+
function ghAvailable(): boolean {
12+
const r = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
13+
return !r.error && r.status === 0;
14+
}
15+
16+
function starRepo(): { ok: boolean; error?: string } {
17+
const r = spawnSync("gh", ["api", "-X", "PUT", `/user/starred/${REPO}`],
18+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true });
19+
if (r.error) return { ok: false, error: r.error.message };
20+
if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || "").trim() || `gh exited ${r.status}` };
21+
return { ok: true };
22+
}
23+
924
/**
10-
* On the FIRST interactive `ocx start`, print a one-time GitHub-star request and drop a marker file
11-
* so it never shows again. No-op for the background service (OCX_SERVICE=1) and non-TTY/piped runs.
25+
* First interactive `ocx start`: a one-time `[Y/n]` "star on GitHub?" prompt. On yes, stars the repo
26+
* via the user's `gh` auth (same approach as the npm postinstall). No-op under the background service,
27+
* for non-TTY/piped runs, when already prompted, or when `gh` is unavailable. Never throws.
1228
*/
13-
export function maybeShowStarPrompt(): void {
14-
if (process.env.OCX_SERVICE || !process.stdout.isTTY) return;
15-
const dir = getConfigDir();
16-
const marker = join(dir, MARKER);
17-
if (existsSync(marker)) return;
29+
export async function maybeShowStarPrompt(): Promise<void> {
1830
try {
19-
mkdirSync(dir, { recursive: true });
20-
writeFileSync(marker, new Date().toISOString());
21-
} catch { /* best-effort: if the marker can't be written, still show it this once */ }
31+
if (process.env.OCX_SERVICE || !process.stdin.isTTY || !process.stdout.isTTY) return;
32+
const dir = getConfigDir();
33+
const marker = join(dir, MARKER);
34+
if (existsSync(marker)) return;
35+
if (!ghAvailable()) return; // can't star without gh — stay silent and re-check on a later start
36+
try { mkdirSync(dir, { recursive: true }); writeFileSync(marker, new Date().toISOString()); } catch { /* best-effort */ }
2237

23-
const RESET = "\x1b[0m", PURPLE = "\x1b[38;5;141m", BOLD = "\x1b[1m", UL = "\x1b[4m", DIM = "\x1b[2m";
24-
console.log("");
25-
console.log(` ${PURPLE}${BOLD}⭐ Enjoying opencodex?${RESET}`);
26-
console.log(" A GitHub star genuinely helps the project grow — thank you!");
27-
console.log(` ${UL}${PURPLE}${REPO_URL}${RESET}`);
28-
console.log(` ${DIM}(shown once)${RESET}`);
29-
console.log("");
38+
const rl = createInterface({ input: process.stdin, output: process.stdout });
39+
let yes = false;
40+
try {
41+
const ans = (await rl.question("\n \x1b[38;5;141m⭐ Enjoying opencodex? Star it on GitHub?\x1b[0m [Y/n] ")).trim().toLowerCase();
42+
yes = ans === "" || ans === "y" || ans === "yes";
43+
} finally {
44+
rl.close();
45+
}
46+
if (!yes) return;
47+
const r = starRepo();
48+
console.log(r.ok ? " Thanks for the star! ⭐\n" : ` Couldn't star automatically (${r.error}) — ${REPO}\n`);
49+
} catch { /* never let the star prompt disrupt startup */ }
3050
}

0 commit comments

Comments
 (0)