clisponsor 1.0.1
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 +38 -0
- package/bin/clisponsor.mjs +540 -0
- package/package.json +22 -0
- package/templates/claude/clisponsor_claude_hook.mjs +52 -0
- package/templates/claude/clisponsor_statusline.mjs +3 -0
- package/templates/codex-plugin/.codex-plugin/plugin.json +26 -0
- package/templates/codex-plugin/hooks/hooks.json +40 -0
- package/templates/codex-plugin/scripts/clisponsor_codex_hook.mjs +56 -0
- package/templates/codex-plugin/skills/clisponsor/SKILL.md +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# CLIsponsor Hook
|
|
2
|
+
|
|
3
|
+
Hook package codebase for the `clisponsor` installer and Codex, Claude Code, and Gemini hook templates.
|
|
4
|
+
|
|
5
|
+
This codebase owns local installation, device login, diagnostics, and hook adapters. It must not contain public website, dashboard app, API account, or ad-serving server code.
|
|
6
|
+
|
|
7
|
+
Current state: authoritative public package source. Retired legacy installer notes are archived under `wiki/legacy` in the workspace.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx clisponsor install
|
|
13
|
+
npx clisponsor login <email>
|
|
14
|
+
npx clisponsor doctor --json
|
|
15
|
+
npx clisponsor uninstall all
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`install` stages the CLIsponsor hooks/plugin for supported local tools. `login` registers this machine with the backend and writes `~/.clisponsor/config.json` with the account UUID and device code.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx clisponsor install
|
|
22
|
+
npx clisponsor login carterjay@gmail.com --label="Work laptop"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Environment defaults:
|
|
26
|
+
|
|
27
|
+
- `CLISPONSOR_SERVE_BASE_URL`, default `https://serve.clisponsor.com`
|
|
28
|
+
- `CLISPONSOR_BACKEND_BASE_URL`, default `https://backend.clisponsor.com`
|
|
29
|
+
- `CLISPONSOR_API_BASE_URL`, legacy fallback for the serve API
|
|
30
|
+
|
|
31
|
+
For compatibility, config still writes `apiBaseUrl` as an alias for `serveBaseUrl`; hook templates prefer `serveBaseUrl` when present. Placements are always reported as `StartSession`, `StartTurn`, or `EndTurn`.
|
|
32
|
+
|
|
33
|
+
## Checks
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm run check
|
|
37
|
+
npm run pack:check
|
|
38
|
+
```
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
9
|
+
const CONFIG_DIR = path.join(HOME, ".clisponsor");
|
|
10
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
11
|
+
const DEFAULT_SERVE_BASE_URL =
|
|
12
|
+
process.env.CLISPONSOR_SERVE_BASE_URL ||
|
|
13
|
+
process.env.CLISPONSOR_API_BASE_URL ||
|
|
14
|
+
"https://serve.clisponsor.com";
|
|
15
|
+
const DEFAULT_BACKEND_BASE_URL = process.env.CLISPONSOR_BACKEND_BASE_URL || "https://backend.clisponsor.com";
|
|
16
|
+
const HOOK_VERSION = "1.0.0";
|
|
17
|
+
const NETWORK_TIMEOUT_MS = 3000;
|
|
18
|
+
|
|
19
|
+
function argValue(name) {
|
|
20
|
+
const prefix = `${name}=`;
|
|
21
|
+
const arg = process.argv.find((item) => item.startsWith(prefix));
|
|
22
|
+
return arg ? arg.slice(prefix.length) : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function positionalArg(index) {
|
|
26
|
+
const value = process.argv[index];
|
|
27
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasFlag(name) {
|
|
31
|
+
return process.argv.includes(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeBaseUrl(value) {
|
|
35
|
+
return String(value || "").trim().replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hostnameFor(value) {
|
|
39
|
+
try {
|
|
40
|
+
return new URL(value).hostname;
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function looksLikeBackendBaseUrl(value) {
|
|
47
|
+
const hostname = hostnameFor(value);
|
|
48
|
+
return hostname === "backend.clisponsor.com" || hostname.startsWith("backend.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function looksLikeServeBaseUrl(value) {
|
|
52
|
+
const hostname = hostnameFor(value);
|
|
53
|
+
return hostname === "serve.clisponsor.com" || hostname.startsWith("serve.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJson(file, fallback) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
63
|
+
} catch {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readEditableJson(file, fallback) {
|
|
69
|
+
if (!fs.existsSync(file)) return fallback;
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`Cannot parse ${file}: ${err.message}`);
|
|
74
|
+
console.error("Fix the JSON before running this command so existing settings are not overwritten.");
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeJson(file, value) {
|
|
80
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
81
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function config() {
|
|
85
|
+
const raw = readJson(CONFIG_PATH, {});
|
|
86
|
+
const legacyApiBaseUrl = normalizeBaseUrl(raw.apiBaseUrl);
|
|
87
|
+
const serveBaseUrl = normalizeBaseUrl(
|
|
88
|
+
raw.serveBaseUrl ||
|
|
89
|
+
(legacyApiBaseUrl && !looksLikeBackendBaseUrl(legacyApiBaseUrl) ? legacyApiBaseUrl : "") ||
|
|
90
|
+
DEFAULT_SERVE_BASE_URL,
|
|
91
|
+
);
|
|
92
|
+
const backendBaseUrl = normalizeBaseUrl(
|
|
93
|
+
raw.backendBaseUrl ||
|
|
94
|
+
(legacyApiBaseUrl && !looksLikeServeBaseUrl(legacyApiBaseUrl) ? legacyApiBaseUrl : "") ||
|
|
95
|
+
DEFAULT_BACKEND_BASE_URL,
|
|
96
|
+
);
|
|
97
|
+
return {
|
|
98
|
+
...raw,
|
|
99
|
+
serveBaseUrl,
|
|
100
|
+
backendBaseUrl,
|
|
101
|
+
apiBaseUrl: serveBaseUrl,
|
|
102
|
+
email: raw.email || "",
|
|
103
|
+
userId: raw.userId || raw.user_id || "",
|
|
104
|
+
deviceCode: raw.deviceCode || raw.device_code || "",
|
|
105
|
+
deviceSecret: raw.deviceSecret || raw.device_secret || "",
|
|
106
|
+
deviceLabel: raw.deviceLabel || raw.device_label || raw.label || "",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function copyDir(src, dest) {
|
|
111
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
112
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
113
|
+
const from = path.join(src, entry.name);
|
|
114
|
+
const to = path.join(dest, entry.name);
|
|
115
|
+
if (entry.isDirectory()) copyDir(from, to);
|
|
116
|
+
else fs.copyFileSync(from, to);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function patchFile(file, replacements) {
|
|
121
|
+
let text = fs.readFileSync(file, "utf8");
|
|
122
|
+
for (const [from, to] of Object.entries(replacements)) text = text.replaceAll(from, to);
|
|
123
|
+
fs.writeFileSync(file, text);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function chmodExecutable(file) {
|
|
127
|
+
try {
|
|
128
|
+
fs.chmodSync(file, 0o755);
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function commandExists(command) {
|
|
133
|
+
try {
|
|
134
|
+
execFileSync(command, ["--version"], { stdio: "ignore" });
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isClisponsorCommand(value) {
|
|
142
|
+
return (
|
|
143
|
+
typeof value === "string" &&
|
|
144
|
+
(value.includes("clisponsor_claude_hook.mjs") ||
|
|
145
|
+
value.includes("clisponsor_gemini_hook.mjs") ||
|
|
146
|
+
value.includes(`${path.sep}.clisponsor${path.sep}`) ||
|
|
147
|
+
value.includes("/.clisponsor/") ||
|
|
148
|
+
value.includes("\\.clisponsor\\"))
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isClisponsorHookEntry(entry) {
|
|
153
|
+
if (!isPlainObject(entry)) return false;
|
|
154
|
+
if (isClisponsorCommand(entry.command)) return true;
|
|
155
|
+
return Array.isArray(entry.hooks) && entry.hooks.some((hook) => isClisponsorHookEntry(hook));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function removeClisponsorHooksFromEntry(entry) {
|
|
159
|
+
if (!isPlainObject(entry)) return { entry, removed: false, empty: false };
|
|
160
|
+
if (isClisponsorCommand(entry.command)) return { removed: true, empty: true };
|
|
161
|
+
if (!Array.isArray(entry.hooks)) return { entry, removed: false, empty: false };
|
|
162
|
+
|
|
163
|
+
let removed = false;
|
|
164
|
+
const nextHooks = [];
|
|
165
|
+
for (const hook of entry.hooks) {
|
|
166
|
+
const result = removeClisponsorHooksFromEntry(hook);
|
|
167
|
+
removed ||= result.removed;
|
|
168
|
+
if (!result.empty) nextHooks.push(result.entry);
|
|
169
|
+
}
|
|
170
|
+
const nextEntry = { ...entry, hooks: nextHooks };
|
|
171
|
+
return { entry: nextEntry, removed, empty: nextHooks.length === 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function addClaudeCommandHook(settings, eventName, command) {
|
|
175
|
+
if (!isPlainObject(settings.hooks)) settings.hooks = {};
|
|
176
|
+
const current = Array.isArray(settings.hooks[eventName]) ? settings.hooks[eventName] : [];
|
|
177
|
+
const exists = current.some((entry) => isClisponsorHookEntry(entry));
|
|
178
|
+
if (exists) return false;
|
|
179
|
+
|
|
180
|
+
settings.hooks[eventName] = [
|
|
181
|
+
...current,
|
|
182
|
+
{
|
|
183
|
+
hooks: [
|
|
184
|
+
{
|
|
185
|
+
type: "command",
|
|
186
|
+
command,
|
|
187
|
+
timeout: 5,
|
|
188
|
+
statusMessage: "Loading sponsor",
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function removeClaudeCommandHooks(settings) {
|
|
197
|
+
if (!isPlainObject(settings.hooks)) return false;
|
|
198
|
+
let changed = false;
|
|
199
|
+
|
|
200
|
+
for (const [eventName, entries] of Object.entries(settings.hooks)) {
|
|
201
|
+
if (!Array.isArray(entries)) continue;
|
|
202
|
+
const nextEntries = [];
|
|
203
|
+
let eventChanged = false;
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const result = removeClisponsorHooksFromEntry(entry);
|
|
206
|
+
eventChanged ||= result.removed;
|
|
207
|
+
if (!result.empty) nextEntries.push(result.entry);
|
|
208
|
+
}
|
|
209
|
+
if (eventChanged) {
|
|
210
|
+
if (nextEntries.length > 0) settings.hooks[eventName] = nextEntries;
|
|
211
|
+
else delete settings.hooks[eventName];
|
|
212
|
+
changed = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (isPlainObject(settings.statusLine) && isClisponsorCommand(settings.statusLine.command)) {
|
|
217
|
+
delete settings.statusLine;
|
|
218
|
+
changed = true;
|
|
219
|
+
}
|
|
220
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
221
|
+
return changed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function registerDevice() {
|
|
225
|
+
const email = positionalArg(3);
|
|
226
|
+
if (!email) {
|
|
227
|
+
console.error("Missing email. Run: clisponsor login <email>");
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
const next = config();
|
|
231
|
+
const serveApiArg = argValue("--serve-api");
|
|
232
|
+
const backendApiArg = argValue("--backend-api");
|
|
233
|
+
const labelArg = argValue("--label");
|
|
234
|
+
|
|
235
|
+
if (serveApiArg) next.serveBaseUrl = normalizeBaseUrl(serveApiArg);
|
|
236
|
+
if (backendApiArg) next.backendBaseUrl = normalizeBaseUrl(backendApiArg);
|
|
237
|
+
next.apiBaseUrl = next.serveBaseUrl;
|
|
238
|
+
|
|
239
|
+
const response = await fetch(`${next.backendBaseUrl}/v1/cli/login`, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/json" },
|
|
242
|
+
body: JSON.stringify({
|
|
243
|
+
email,
|
|
244
|
+
label: labelArg || next.deviceLabel || undefined,
|
|
245
|
+
serve_base_url: next.serveBaseUrl,
|
|
246
|
+
}),
|
|
247
|
+
signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS),
|
|
248
|
+
});
|
|
249
|
+
let payload = {};
|
|
250
|
+
try {
|
|
251
|
+
payload = await response.json();
|
|
252
|
+
} catch {}
|
|
253
|
+
if (!response.ok) {
|
|
254
|
+
const detail = payload.detail || payload.error || `HTTP ${response.status}`;
|
|
255
|
+
console.error(`CLIsponsor login failed: ${detail}`);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
next.email = payload.email || email;
|
|
260
|
+
next.userId = payload.user_id || payload.userId;
|
|
261
|
+
next.deviceCode = payload.device_code || payload.deviceCode;
|
|
262
|
+
next.deviceSecret = payload.device_secret || payload.deviceSecret || "";
|
|
263
|
+
next.deviceLabel = payload.label || labelArg || next.deviceLabel || "";
|
|
264
|
+
if (!next.userId || !next.deviceCode || !next.deviceSecret) {
|
|
265
|
+
console.error("CLIsponsor login failed: backend response did not include user_id, device_code, and device_secret.");
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
writeJson(CONFIG_PATH, next);
|
|
269
|
+
console.log(`Logged in ${next.email}`);
|
|
270
|
+
console.log(`Device code: ${next.deviceCode}`);
|
|
271
|
+
return next;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function login() {
|
|
275
|
+
await registerDevice();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function installCodex() {
|
|
279
|
+
const pluginRoot = path.join(CONFIG_DIR, "codex-plugin");
|
|
280
|
+
copyDir(path.join(ROOT, "templates", "codex-plugin"), pluginRoot);
|
|
281
|
+
patchFile(path.join(pluginRoot, "hooks", "hooks.json"), {
|
|
282
|
+
"__CLISPONSOR_HOOK_SCRIPT__": path.join(pluginRoot, "scripts", "clisponsor_codex_hook.mjs"),
|
|
283
|
+
});
|
|
284
|
+
patchFile(path.join(pluginRoot, "scripts", "clisponsor_codex_hook.mjs"), {
|
|
285
|
+
"__CLISPONSOR_CONFIG_PATH__": CONFIG_PATH,
|
|
286
|
+
});
|
|
287
|
+
chmodExecutable(path.join(pluginRoot, "scripts", "clisponsor_codex_hook.mjs"));
|
|
288
|
+
|
|
289
|
+
const marketplaceRoot = path.join(CONFIG_DIR, "codex-marketplace");
|
|
290
|
+
const marketplacePath = path.join(marketplaceRoot, ".agents", "plugins", "marketplace.json");
|
|
291
|
+
fs.mkdirSync(path.dirname(marketplacePath), { recursive: true });
|
|
292
|
+
fs.mkdirSync(path.join(marketplaceRoot, "plugins"), { recursive: true });
|
|
293
|
+
const linkedPlugin = path.join(marketplaceRoot, "plugins", "clisponsor");
|
|
294
|
+
fs.rmSync(linkedPlugin, { recursive: true, force: true });
|
|
295
|
+
copyDir(pluginRoot, linkedPlugin);
|
|
296
|
+
writeJson(marketplacePath, {
|
|
297
|
+
name: "clisponsor-local",
|
|
298
|
+
interface: { displayName: "CLIsponsor Local" },
|
|
299
|
+
plugins: [
|
|
300
|
+
{
|
|
301
|
+
name: "clisponsor",
|
|
302
|
+
source: { source: "local", path: "./plugins/clisponsor" },
|
|
303
|
+
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
|
304
|
+
category: "Productivity",
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
try {
|
|
309
|
+
execFileSync("codex", ["plugin", "marketplace", "add", marketplaceRoot], { stdio: "ignore" });
|
|
310
|
+
} catch {}
|
|
311
|
+
try {
|
|
312
|
+
execFileSync("codex", ["plugin", "add", "clisponsor@clisponsor-local"], { stdio: "ignore" });
|
|
313
|
+
console.log("Codex CLI plugin installed.");
|
|
314
|
+
} catch {
|
|
315
|
+
console.log("Codex CLI plugin staged. After installing Codex CLI, rerun: npx clisponsor install");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function installClaude() {
|
|
320
|
+
const claudeDir = path.join(CONFIG_DIR, "claude");
|
|
321
|
+
const installedHook = path.join(claudeDir, "clisponsor_claude_hook.mjs");
|
|
322
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
323
|
+
fs.copyFileSync(path.join(ROOT, "templates", "claude", "clisponsor_claude_hook.mjs"), installedHook);
|
|
324
|
+
chmodExecutable(installedHook);
|
|
325
|
+
|
|
326
|
+
if (!commandExists("claude")) {
|
|
327
|
+
console.log("Claude Code CLI hook staged. After installing Claude Code CLI, rerun: npx clisponsor install");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const settingsPath = path.join(HOME, ".claude", "settings.json");
|
|
332
|
+
const settings = readEditableJson(settingsPath, {});
|
|
333
|
+
addClaudeCommandHook(settings, "SessionStart", `node ${JSON.stringify(installedHook)} SessionStart`);
|
|
334
|
+
addClaudeCommandHook(settings, "UserPromptSubmit", `node ${JSON.stringify(installedHook)} UserPromptSubmit`);
|
|
335
|
+
addClaudeCommandHook(settings, "Stop", `node ${JSON.stringify(installedHook)} Stop`);
|
|
336
|
+
writeJson(settingsPath, settings);
|
|
337
|
+
console.log(`Updated ${settingsPath}`);
|
|
338
|
+
console.log("Claude Code CLI hook installed.");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function geminiHookSource() {
|
|
342
|
+
return `#!/usr/bin/env node
|
|
343
|
+
import fs from "node:fs";
|
|
344
|
+
import crypto from "node:crypto";
|
|
345
|
+
const cfg = JSON.parse(fs.readFileSync(${JSON.stringify(CONFIG_PATH)}, "utf8"));
|
|
346
|
+
const event = process.argv[2] || "StartTurn";
|
|
347
|
+
const placements = { SessionStart: "StartSession", UserPromptSubmit: "StartTurn", Stop: "EndTurn", StartTurn: "StartTurn" };
|
|
348
|
+
const serveBaseUrl = cfg.serveBaseUrl || cfg.apiBaseUrl;
|
|
349
|
+
try {
|
|
350
|
+
if (!serveBaseUrl || !cfg.userId || !cfg.deviceCode || !cfg.deviceSecret) process.exit(0);
|
|
351
|
+
const placement = placements[event] || event;
|
|
352
|
+
const body = { user_id: cfg.userId, device_code: cfg.deviceCode, client: "Gemini", hook_event: event, placement, idempotency_key: crypto.randomUUID(), metadata: { hookVersion: ${JSON.stringify(HOOK_VERSION)} } };
|
|
353
|
+
const res = await fetch(serveBaseUrl + "/v1/ads/serve", {
|
|
354
|
+
method: "POST",
|
|
355
|
+
headers: {
|
|
356
|
+
"content-type": "application/json",
|
|
357
|
+
"authorization": "Bearer " + cfg.deviceSecret,
|
|
358
|
+
"x-clisponsor-hook-version": ${JSON.stringify(HOOK_VERSION)}
|
|
359
|
+
},
|
|
360
|
+
body: JSON.stringify(body)
|
|
361
|
+
});
|
|
362
|
+
if (res.ok) {
|
|
363
|
+
const ad = await res.json();
|
|
364
|
+
if (ad.display_line) console.log(ad.display_line);
|
|
365
|
+
}
|
|
366
|
+
} catch {}
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function installGemini() {
|
|
371
|
+
const geminiDir = path.join(CONFIG_DIR, "gemini");
|
|
372
|
+
fs.mkdirSync(geminiDir, { recursive: true });
|
|
373
|
+
const hookPath = path.join(geminiDir, "clisponsor_gemini_hook.mjs");
|
|
374
|
+
fs.writeFileSync(hookPath, geminiHookSource(), { mode: 0o755 });
|
|
375
|
+
if (commandExists("gemini")) {
|
|
376
|
+
console.log("Gemini CLI hook script staged.");
|
|
377
|
+
console.log("Gemini CLI does not expose a CLIsponsor auto-configuration target yet; configure it to run:");
|
|
378
|
+
console.log(`node ${JSON.stringify(hookPath)} StartTurn`);
|
|
379
|
+
} else {
|
|
380
|
+
console.log("Gemini CLI hook script staged. After installing Gemini CLI, rerun: npx clisponsor install");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function installAll() {
|
|
385
|
+
installCodex();
|
|
386
|
+
installClaude();
|
|
387
|
+
installGemini();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function uninstallCodex() {
|
|
391
|
+
fs.rmSync(path.join(CONFIG_DIR, "codex-plugin"), { recursive: true, force: true });
|
|
392
|
+
fs.rmSync(path.join(CONFIG_DIR, "codex-marketplace"), { recursive: true, force: true });
|
|
393
|
+
console.log("Removed staged Codex plugin files.");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function uninstallClaude() {
|
|
397
|
+
const settingsPath = path.join(HOME, ".claude", "settings.json");
|
|
398
|
+
const settings = readEditableJson(settingsPath, {});
|
|
399
|
+
if (removeClaudeCommandHooks(settings)) {
|
|
400
|
+
writeJson(settingsPath, settings);
|
|
401
|
+
console.log(`Removed CLIsponsor hooks from ${settingsPath}`);
|
|
402
|
+
} else {
|
|
403
|
+
console.log("No CLIsponsor Claude hooks found.");
|
|
404
|
+
}
|
|
405
|
+
fs.rmSync(path.join(CONFIG_DIR, "claude"), { recursive: true, force: true });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function uninstallGemini() {
|
|
409
|
+
fs.rmSync(path.join(CONFIG_DIR, "gemini", "clisponsor_gemini_hook.mjs"), { force: true });
|
|
410
|
+
try {
|
|
411
|
+
fs.rmdirSync(path.join(CONFIG_DIR, "gemini"));
|
|
412
|
+
} catch {}
|
|
413
|
+
console.log("Removed Gemini hook script.");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function uninstall() {
|
|
417
|
+
const target = process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : "all";
|
|
418
|
+
if (!["all", "codex", "claude", "gemini"].includes(target)) {
|
|
419
|
+
console.error("Unknown uninstall target. Use: codex, claude, gemini, or all.");
|
|
420
|
+
process.exit(1);
|
|
421
|
+
}
|
|
422
|
+
if (target === "all" || target === "codex") uninstallCodex();
|
|
423
|
+
if (target === "all" || target === "claude") uninstallClaude();
|
|
424
|
+
if (target === "all" || target === "gemini") uninstallGemini();
|
|
425
|
+
if (hasFlag("--config")) {
|
|
426
|
+
fs.rmSync(CONFIG_PATH, { force: true });
|
|
427
|
+
console.log(`Removed ${CONFIG_PATH}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function fetchProbe(url, headers = {}) {
|
|
432
|
+
try {
|
|
433
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS) });
|
|
434
|
+
let body = null;
|
|
435
|
+
try {
|
|
436
|
+
body = await res.json();
|
|
437
|
+
} catch {
|
|
438
|
+
body = await res.text();
|
|
439
|
+
}
|
|
440
|
+
return { ok: res.ok, status: res.status, body };
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return { ok: false, error: err.message };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function status() {
|
|
447
|
+
const cfg = config();
|
|
448
|
+
if (!cfg.userId || !cfg.deviceCode) {
|
|
449
|
+
console.error("Not logged in. Run: clisponsor login <email>");
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
console.log(JSON.stringify({
|
|
453
|
+
email: cfg.email,
|
|
454
|
+
user_id: cfg.userId,
|
|
455
|
+
device_code: cfg.deviceCode,
|
|
456
|
+
has_device_secret: Boolean(cfg.deviceSecret),
|
|
457
|
+
label: cfg.deviceLabel || null,
|
|
458
|
+
serveBaseUrl: cfg.serveBaseUrl,
|
|
459
|
+
backendBaseUrl: cfg.backendBaseUrl,
|
|
460
|
+
}, null, 2));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function doctor() {
|
|
464
|
+
const cfg = config();
|
|
465
|
+
const json = hasFlag("--json");
|
|
466
|
+
const skipNetwork = hasFlag("--skip-network");
|
|
467
|
+
const diagnostics = {
|
|
468
|
+
version: HOOK_VERSION,
|
|
469
|
+
configPath: CONFIG_PATH,
|
|
470
|
+
configExists: fs.existsSync(CONFIG_PATH),
|
|
471
|
+
serveBaseUrl: cfg.serveBaseUrl,
|
|
472
|
+
backendBaseUrl: cfg.backendBaseUrl,
|
|
473
|
+
loggedIn: Boolean(cfg.userId && cfg.deviceCode),
|
|
474
|
+
hasDeviceSecret: Boolean(cfg.deviceSecret),
|
|
475
|
+
email: cfg.email || null,
|
|
476
|
+
userId: cfg.userId || null,
|
|
477
|
+
deviceCode: cfg.deviceCode || null,
|
|
478
|
+
installed: {
|
|
479
|
+
codexPluginStaged: fs.existsSync(path.join(CONFIG_DIR, "codex-marketplace", "plugins", "clisponsor")),
|
|
480
|
+
claudeSettings: fs.existsSync(path.join(HOME, ".claude", "settings.json")),
|
|
481
|
+
claudeHookScript: fs.existsSync(path.join(CONFIG_DIR, "claude", "clisponsor_claude_hook.mjs")),
|
|
482
|
+
geminiHookScript: fs.existsSync(path.join(CONFIG_DIR, "gemini", "clisponsor_gemini_hook.mjs")),
|
|
483
|
+
},
|
|
484
|
+
network: {},
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
if (!skipNetwork) {
|
|
488
|
+
diagnostics.network.serveHealth = await fetchProbe(`${cfg.serveBaseUrl}/healthz`);
|
|
489
|
+
diagnostics.network.backendHealth = await fetchProbe(`${cfg.backendBaseUrl}/healthz`);
|
|
490
|
+
diagnostics.network.cliLoginEndpoint = await fetchProbe(`${cfg.backendBaseUrl}/healthz`);
|
|
491
|
+
} else {
|
|
492
|
+
diagnostics.network.skipped = true;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (json) {
|
|
496
|
+
console.log(JSON.stringify(diagnostics, null, 2));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
console.log(`Version: ${diagnostics.version}`);
|
|
501
|
+
console.log(`Config: ${diagnostics.configPath}`);
|
|
502
|
+
console.log(`Logged in: ${diagnostics.loggedIn ? "yes" : "no"}`);
|
|
503
|
+
if (diagnostics.email) console.log(`Email: ${diagnostics.email}`);
|
|
504
|
+
if (diagnostics.deviceCode) console.log(`Device code: ${diagnostics.deviceCode}`);
|
|
505
|
+
console.log(`Codex plugin staged: ${diagnostics.installed.codexPluginStaged ? "yes" : "no"}`);
|
|
506
|
+
console.log(`Claude settings: ${diagnostics.installed.claudeSettings ? "yes" : "no"}`);
|
|
507
|
+
console.log(`Claude hook script: ${diagnostics.installed.claudeHookScript ? "yes" : "no"}`);
|
|
508
|
+
console.log(`Gemini hook script: ${diagnostics.installed.geminiHookScript ? "yes" : "no"}`);
|
|
509
|
+
if (skipNetwork) {
|
|
510
|
+
console.log("Network: skipped");
|
|
511
|
+
} else {
|
|
512
|
+
console.log(`Serve health: ${diagnostics.network.serveHealth.status || "unavailable"}`);
|
|
513
|
+
console.log(`Backend health: ${diagnostics.network.backendHealth.status || "unavailable"}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function help() {
|
|
518
|
+
console.log(`clisponsor commands:
|
|
519
|
+
clisponsor install
|
|
520
|
+
clisponsor login <email> [--label=<device-label>]
|
|
521
|
+
clisponsor uninstall [--config]
|
|
522
|
+
clisponsor status
|
|
523
|
+
clisponsor doctor [--json] [--skip-network]
|
|
524
|
+
|
|
525
|
+
Environment:
|
|
526
|
+
CLISPONSOR_BACKEND_BASE_URL and CLISPONSOR_SERVE_BASE_URL override production endpoints.`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const command = process.argv[2] || "help";
|
|
530
|
+
if (command === "login") await login();
|
|
531
|
+
else if (command === "install" && (!process.argv[3] || process.argv[3] === "all")) installAll();
|
|
532
|
+
else if (command === "uninstall") uninstall();
|
|
533
|
+
else if (command === "status") await status();
|
|
534
|
+
else if (command === "doctor") await doctor();
|
|
535
|
+
else if (command === "help" || command === "--help" || command === "-h") help();
|
|
536
|
+
else {
|
|
537
|
+
console.error(`Unknown command: ${command}`);
|
|
538
|
+
help();
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clisponsor",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "CLIsponsor installer for Codex, Claude Code, and Gemini sponsored CLI placements.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"clisponsor": "bin/clisponsor.mjs"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"check": "node --check bin/clisponsor.mjs && node --check templates/codex-plugin/scripts/clisponsor_codex_hook.mjs && node --check templates/claude/clisponsor_claude_hook.mjs && node --check templates/claude/clisponsor_statusline.mjs && npm run test",
|
|
14
|
+
"test": "node scripts/test-installer.mjs",
|
|
15
|
+
"pack:check": "npm pack --dry-run"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"templates/"
|
|
20
|
+
],
|
|
21
|
+
"license": "UNLICENSED"
|
|
22
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const HOOK_VERSION = "1.0.0";
|
|
8
|
+
const event = process.argv[2] || "UserPromptSubmit";
|
|
9
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".clisponsor", "config.json"), "utf8"));
|
|
10
|
+
const serveBaseUrl = cfg.serveBaseUrl || cfg.apiBaseUrl;
|
|
11
|
+
const placements = {
|
|
12
|
+
SessionStart: "StartSession",
|
|
13
|
+
UserPromptSubmit: "StartTurn",
|
|
14
|
+
Stop: "EndTurn",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function readStdin() {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let data = "";
|
|
20
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
21
|
+
process.stdin.on("end", () => resolve(data));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await readStdin();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (!serveBaseUrl || !cfg.userId || !cfg.deviceCode || !cfg.deviceSecret) process.exit(0);
|
|
29
|
+
const body = {
|
|
30
|
+
user_id: cfg.userId,
|
|
31
|
+
device_code: cfg.deviceCode,
|
|
32
|
+
client: "ClaudeCode",
|
|
33
|
+
hook_event: event,
|
|
34
|
+
placement: placements[event] || "StartTurn",
|
|
35
|
+
idempotency_key: crypto.randomUUID(),
|
|
36
|
+
metadata: { hookVersion: HOOK_VERSION },
|
|
37
|
+
};
|
|
38
|
+
const res = await fetch(`${serveBaseUrl}/v1/ads/serve`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
"authorization": `Bearer ${cfg.deviceSecret}`,
|
|
43
|
+
"x-clisponsor-hook-version": HOOK_VERSION,
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) process.exit(0);
|
|
48
|
+
const ad = await res.json();
|
|
49
|
+
if (ad.display_line) console.log(JSON.stringify({ systemMessage: ad.display_line }));
|
|
50
|
+
} catch {
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clisponsor",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Display sponsored CLI ads in Codex hooks and track impressions.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "CLIsponsor",
|
|
7
|
+
"email": "support@clisponsor.com",
|
|
8
|
+
"url": "https://clisponsor.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "UNLICENSED",
|
|
11
|
+
"keywords": ["ads", "codex", "hooks"],
|
|
12
|
+
"skills": "./skills/",
|
|
13
|
+
"interface": {
|
|
14
|
+
"displayName": "CLIsponsor",
|
|
15
|
+
"shortDescription": "Sponsored CLI lines for Codex.",
|
|
16
|
+
"longDescription": "Fetches short sponsored lines from the CLIsponsor network and records impressions.",
|
|
17
|
+
"developerName": "CLIsponsor",
|
|
18
|
+
"category": "Productivity",
|
|
19
|
+
"capabilities": ["Hooks"],
|
|
20
|
+
"websiteURL": "https://clisponsor.com",
|
|
21
|
+
"privacyPolicyURL": "https://clisponsor.com/#privacy",
|
|
22
|
+
"termsOfServiceURL": "https://clisponsor.com",
|
|
23
|
+
"defaultPrompt": ["Use CLIsponsor in this session."],
|
|
24
|
+
"brandColor": "#2563EB"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "startup|resume|clear|compact",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"__CLISPONSOR_HOOK_SCRIPT__\" SessionStart",
|
|
10
|
+
"timeout": 5,
|
|
11
|
+
"statusMessage": "Loading sponsor"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"UserPromptSubmit": [
|
|
17
|
+
{
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node \"__CLISPONSOR_HOOK_SCRIPT__\" UserPromptSubmit",
|
|
22
|
+
"timeout": 5,
|
|
23
|
+
"statusMessage": "Loading sponsor"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"Stop": [
|
|
29
|
+
{
|
|
30
|
+
"hooks": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "node \"__CLISPONSOR_HOOK_SCRIPT__\" Stop",
|
|
34
|
+
"timeout": 5
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = "__CLISPONSOR_CONFIG_PATH__";
|
|
6
|
+
const HOOK_VERSION = "1.0.0";
|
|
7
|
+
const EVENT = process.argv[2] || "UserPromptSubmit";
|
|
8
|
+
const PLACEMENTS = {
|
|
9
|
+
SessionStart: "StartSession",
|
|
10
|
+
UserPromptSubmit: "StartTurn",
|
|
11
|
+
Stop: "EndTurn",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function readStdin() {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let data = "";
|
|
17
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
18
|
+
process.stdin.on("end", () => resolve(data));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readConfig() {
|
|
23
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cfg = readConfig();
|
|
27
|
+
const serveBaseUrl = cfg.serveBaseUrl || cfg.apiBaseUrl;
|
|
28
|
+
await readStdin();
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
if (!serveBaseUrl || !cfg.userId || !cfg.deviceCode || !cfg.deviceSecret) process.exit(0);
|
|
32
|
+
const placement = PLACEMENTS[EVENT] || "StartTurn";
|
|
33
|
+
const body = {
|
|
34
|
+
user_id: cfg.userId,
|
|
35
|
+
device_code: cfg.deviceCode,
|
|
36
|
+
client: "Codex",
|
|
37
|
+
hook_event: EVENT,
|
|
38
|
+
placement,
|
|
39
|
+
idempotency_key: crypto.randomUUID(),
|
|
40
|
+
metadata: { hookVersion: HOOK_VERSION },
|
|
41
|
+
};
|
|
42
|
+
const res = await fetch(`${serveBaseUrl}/v1/ads/serve`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"content-type": "application/json",
|
|
46
|
+
"authorization": `Bearer ${cfg.deviceSecret}`,
|
|
47
|
+
"x-clisponsor-hook-version": HOOK_VERSION,
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) process.exit(0);
|
|
52
|
+
const ad = await res.json();
|
|
53
|
+
if (ad.display_line) console.log(JSON.stringify({ systemMessage: ad.display_line }));
|
|
54
|
+
} catch {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: CLIsponsor
|
|
3
|
+
description: Use CLIsponsor hooks to display sponsored CLI lines and track impressions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
CLIsponsor is installed by `npx clisponsor install`, then registered with `npx clisponsor login <email>`.
|
|
7
|
+
It fetches short sponsored lines from the configured API and records requested ads and served ads.
|