codex-wakatime 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/index.cjs +1070 -0
  4. package/package.json +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 WakaTime
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # codex-wakatime
2
+
3
+ WakaTime integration for [OpenAI Codex CLI](https://github.com/openai/codex) - Track AI coding activity and time spent.
4
+
5
+ ## Features
6
+
7
+ - Automatic time tracking for Codex CLI sessions
8
+ - File-level activity detection via message parsing
9
+ - 60-second heartbeat rate limiting
10
+ - Automatic WakaTime CLI installation and updates
11
+ - Cross-platform support (macOS, Linux, Windows)
12
+
13
+ ## Prerequisites
14
+
15
+ 1. [WakaTime account](https://wakatime.com) and API key
16
+ 2. WakaTime API key configured in `~/.wakatime.cfg`:
17
+ ```ini
18
+ [settings]
19
+ api_key = your-api-key-here
20
+ ```
21
+ 3. [Codex CLI](https://github.com/openai/codex) installed
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ # Install the package
27
+ npm install -g codex-wakatime
28
+
29
+ # Configure the notification hook
30
+ codex-wakatime --install
31
+ ```
32
+
33
+ This adds `notify = ["codex-wakatime"]` to your `~/.codex/config.toml`.
34
+
35
+ ## How It Works
36
+
37
+ ```
38
+ ┌─────────────────────────────────────────────────────────────┐
39
+ │ Codex CLI Session │
40
+ └─────────────────────────┬───────────────────────────────────┘
41
+
42
+
43
+ ┌─────────────────────────────────────────────────────────────┐
44
+ │ agent-turn-complete event │
45
+ │ Codex sends notification with: │
46
+ │ - thread-id, turn-id │
47
+ │ - cwd (working directory) │
48
+ │ - last-assistant-message │
49
+ └─────────────────────────┬───────────────────────────────────┘
50
+
51
+
52
+ ┌─────────────────────────────────────────────────────────────┐
53
+ │ codex-wakatime │
54
+ │ 1. Parse notification JSON from CLI argument │
55
+ │ 2. Extract file paths from assistant message │
56
+ │ 3. Check 60-second rate limit │
57
+ │ 4. Send heartbeat(s) to WakaTime │
58
+ └─────────────────────────┬───────────────────────────────────┘
59
+
60
+
61
+ ┌─────────────────────────────────────────────────────────────┐
62
+ │ WakaTime Dashboard │
63
+ │ View your AI coding metrics at wakatime.com │
64
+ └─────────────────────────────────────────────────────────────┘
65
+ ```
66
+
67
+ ### Notification Hook
68
+
69
+ | Event | Purpose |
70
+ |-------|---------|
71
+ | `agent-turn-complete` | Triggered after each Codex turn completes |
72
+
73
+ ### File Detection Patterns
74
+
75
+ The plugin extracts file paths from the assistant's response using these patterns:
76
+
77
+ - **Code block headers**: ` ```typescript:src/index.ts `
78
+ - **Backtick paths**: `` `src/file.ts` ``
79
+ - **Action patterns**: `Created src/file.ts`, `Modified package.json`
80
+ - **Quoted paths**: `"src/file.ts"` or `'src/file.ts'`
81
+
82
+ If no files are detected, a project-level heartbeat is sent using the working directory.
83
+
84
+ ## Configuration
85
+
86
+ The plugin auto-configures `~/.codex/config.toml` on installation:
87
+
88
+ ```toml
89
+ notify = ["codex-wakatime"]
90
+ ```
91
+
92
+ ### Debug Mode
93
+
94
+ Enable debug logging by adding to `~/.wakatime.cfg`:
95
+
96
+ ```ini
97
+ [settings]
98
+ debug = true
99
+ ```
100
+
101
+ Logs are written to `~/.wakatime/codex.log`.
102
+
103
+ ## Files & Locations
104
+
105
+ | File | Purpose |
106
+ |------|---------|
107
+ | `~/.wakatime/codex.json` | Rate limiting state |
108
+ | `~/.wakatime/codex.log` | Debug logs |
109
+ | `~/.wakatime/codex-cli-state.json` | CLI version tracking |
110
+ | `~/.codex/config.toml` | Codex configuration |
111
+ | `~/.wakatime.cfg` | WakaTime API key and settings |
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ # Clone the repository
117
+ git clone https://github.com/wakatime/codex-wakatime
118
+ cd codex-wakatime
119
+
120
+ # Install dependencies
121
+ npm install
122
+
123
+ # Build
124
+ npm run build
125
+
126
+ # Run tests
127
+ npm test
128
+
129
+ # Type check
130
+ npm run typecheck
131
+
132
+ # Lint
133
+ npm run check
134
+ ```
135
+
136
+ ### Project Structure
137
+
138
+ ```
139
+ codex-wakatime/
140
+ ├── src/
141
+ │ ├── index.ts # Main entry point
142
+ │ ├── install.ts # Hook installation
143
+ │ ├── extractor.ts # File path extraction
144
+ │ ├── wakatime.ts # CLI invocation
145
+ │ ├── dependencies.ts # CLI management
146
+ │ ├── state.ts # Rate limiting
147
+ │ ├── logger.ts # Logging
148
+ │ ├── options.ts # Config parsing
149
+ │ ├── types.ts # TypeScript interfaces
150
+ │ └── __tests__/ # Test files
151
+ ├── scripts/
152
+ │ └── generate-version.js
153
+ ├── package.json
154
+ ├── tsconfig.json
155
+ └── biome.json
156
+ ```
157
+
158
+ ## Uninstall
159
+
160
+ ```bash
161
+ # Remove the notification hook
162
+ codex-wakatime --uninstall
163
+
164
+ # Uninstall the package
165
+ npm uninstall -g codex-wakatime
166
+ ```
167
+
168
+ ## Commands
169
+
170
+ | Command | Description |
171
+ |---------|-------------|
172
+ | `codex-wakatime --install` | Add notification hook to Codex config |
173
+ | `codex-wakatime --uninstall` | Remove notification hook from Codex config |
174
+ | `codex-wakatime '{"type":"agent-turn-complete",...}'` | Process a notification (called by Codex) |
175
+
176
+ ## Troubleshooting
177
+
178
+ ### No heartbeats being sent
179
+
180
+ 1. Check that your API key is configured in `~/.wakatime.cfg`
181
+ 2. Verify the notify hook is set in `~/.codex/config.toml`
182
+ 3. Enable debug mode and check `~/.wakatime/codex.log`
183
+
184
+ ### Rate limiting
185
+
186
+ Heartbeats are rate-limited to once per 60 seconds. If you're testing, wait at least 60 seconds between Codex turns.
187
+
188
+ ### CLI not found
189
+
190
+ The plugin automatically downloads `wakatime-cli` if not found. If this fails:
191
+
192
+ 1. Check your internet connection
193
+ 2. Manually install: https://github.com/wakatime/wakatime-cli/releases
194
+ 3. Ensure `wakatime-cli` is in your PATH
195
+
196
+ ## License
197
+
198
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,1070 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __commonJS = (cb, mod) => function __require() {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+
29
+ // node_modules/isexe/dist/cjs/posix.js
30
+ var require_posix = __commonJS({
31
+ "node_modules/isexe/dist/cjs/posix.js"(exports2) {
32
+ "use strict";
33
+ Object.defineProperty(exports2, "__esModule", { value: true });
34
+ exports2.sync = exports2.isexe = void 0;
35
+ var fs_1 = require("fs");
36
+ var promises_1 = require("fs/promises");
37
+ var isexe = async (path8, options = {}) => {
38
+ const { ignoreErrors = false } = options;
39
+ try {
40
+ return checkStat(await (0, promises_1.stat)(path8), options);
41
+ } catch (e) {
42
+ const er = e;
43
+ if (ignoreErrors || er.code === "EACCES")
44
+ return false;
45
+ throw er;
46
+ }
47
+ };
48
+ exports2.isexe = isexe;
49
+ var sync = (path8, options = {}) => {
50
+ const { ignoreErrors = false } = options;
51
+ try {
52
+ return checkStat((0, fs_1.statSync)(path8), options);
53
+ } catch (e) {
54
+ const er = e;
55
+ if (ignoreErrors || er.code === "EACCES")
56
+ return false;
57
+ throw er;
58
+ }
59
+ };
60
+ exports2.sync = sync;
61
+ var checkStat = (stat, options) => stat.isFile() && checkMode(stat, options);
62
+ var checkMode = (stat, options) => {
63
+ const myUid = options.uid ?? process.getuid?.();
64
+ const myGroups = options.groups ?? process.getgroups?.() ?? [];
65
+ const myGid = options.gid ?? process.getgid?.() ?? myGroups[0];
66
+ if (myUid === void 0 || myGid === void 0) {
67
+ throw new Error("cannot get uid or gid");
68
+ }
69
+ const groups = /* @__PURE__ */ new Set([myGid, ...myGroups]);
70
+ const mod = stat.mode;
71
+ const uid = stat.uid;
72
+ const gid = stat.gid;
73
+ const u = parseInt("100", 8);
74
+ const g = parseInt("010", 8);
75
+ const o = parseInt("001", 8);
76
+ const ug = u | g;
77
+ return !!(mod & o || mod & g && groups.has(gid) || mod & u && uid === myUid || mod & ug && myUid === 0);
78
+ };
79
+ }
80
+ });
81
+
82
+ // node_modules/isexe/dist/cjs/win32.js
83
+ var require_win32 = __commonJS({
84
+ "node_modules/isexe/dist/cjs/win32.js"(exports2) {
85
+ "use strict";
86
+ Object.defineProperty(exports2, "__esModule", { value: true });
87
+ exports2.sync = exports2.isexe = void 0;
88
+ var fs_1 = require("fs");
89
+ var promises_1 = require("fs/promises");
90
+ var isexe = async (path8, options = {}) => {
91
+ const { ignoreErrors = false } = options;
92
+ try {
93
+ return checkStat(await (0, promises_1.stat)(path8), path8, options);
94
+ } catch (e) {
95
+ const er = e;
96
+ if (ignoreErrors || er.code === "EACCES")
97
+ return false;
98
+ throw er;
99
+ }
100
+ };
101
+ exports2.isexe = isexe;
102
+ var sync = (path8, options = {}) => {
103
+ const { ignoreErrors = false } = options;
104
+ try {
105
+ return checkStat((0, fs_1.statSync)(path8), path8, options);
106
+ } catch (e) {
107
+ const er = e;
108
+ if (ignoreErrors || er.code === "EACCES")
109
+ return false;
110
+ throw er;
111
+ }
112
+ };
113
+ exports2.sync = sync;
114
+ var checkPathExt = (path8, options) => {
115
+ const { pathExt = process.env.PATHEXT || "" } = options;
116
+ const peSplit = pathExt.split(";");
117
+ if (peSplit.indexOf("") !== -1) {
118
+ return true;
119
+ }
120
+ for (let i = 0; i < peSplit.length; i++) {
121
+ const p = peSplit[i].toLowerCase();
122
+ const ext = path8.substring(path8.length - p.length).toLowerCase();
123
+ if (p && ext === p) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ };
129
+ var checkStat = (stat, path8, options) => stat.isFile() && checkPathExt(path8, options);
130
+ }
131
+ });
132
+
133
+ // node_modules/isexe/dist/cjs/options.js
134
+ var require_options = __commonJS({
135
+ "node_modules/isexe/dist/cjs/options.js"(exports2) {
136
+ "use strict";
137
+ Object.defineProperty(exports2, "__esModule", { value: true });
138
+ }
139
+ });
140
+
141
+ // node_modules/isexe/dist/cjs/index.js
142
+ var require_cjs = __commonJS({
143
+ "node_modules/isexe/dist/cjs/index.js"(exports2) {
144
+ "use strict";
145
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
146
+ if (k2 === void 0) k2 = k;
147
+ var desc = Object.getOwnPropertyDescriptor(m, k);
148
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
149
+ desc = { enumerable: true, get: function() {
150
+ return m[k];
151
+ } };
152
+ }
153
+ Object.defineProperty(o, k2, desc);
154
+ }) : (function(o, m, k, k2) {
155
+ if (k2 === void 0) k2 = k;
156
+ o[k2] = m[k];
157
+ }));
158
+ var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
159
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
160
+ }) : function(o, v) {
161
+ o["default"] = v;
162
+ });
163
+ var __importStar = exports2 && exports2.__importStar || function(mod) {
164
+ if (mod && mod.__esModule) return mod;
165
+ var result = {};
166
+ if (mod != null) {
167
+ for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
168
+ }
169
+ __setModuleDefault(result, mod);
170
+ return result;
171
+ };
172
+ var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) {
173
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) __createBinding(exports3, m, p);
174
+ };
175
+ Object.defineProperty(exports2, "__esModule", { value: true });
176
+ exports2.sync = exports2.isexe = exports2.posix = exports2.win32 = void 0;
177
+ var posix = __importStar(require_posix());
178
+ exports2.posix = posix;
179
+ var win32 = __importStar(require_win32());
180
+ exports2.win32 = win32;
181
+ __exportStar(require_options(), exports2);
182
+ var platform3 = process.env._ISEXE_TEST_PLATFORM_ || process.platform;
183
+ var impl = platform3 === "win32" ? win32 : posix;
184
+ exports2.isexe = impl.isexe;
185
+ exports2.sync = impl.sync;
186
+ }
187
+ });
188
+
189
+ // node_modules/which/lib/index.js
190
+ var require_lib = __commonJS({
191
+ "node_modules/which/lib/index.js"(exports2, module2) {
192
+ var { isexe, sync: isexeSync } = require_cjs();
193
+ var { join: join7, delimiter, sep, posix } = require("path");
194
+ var isWindows2 = process.platform === "win32";
195
+ var rSlash = new RegExp(`[${posix.sep}${sep === posix.sep ? "" : sep}]`.replace(/(\\)/g, "\\$1"));
196
+ var rRel = new RegExp(`^\\.${rSlash.source}`);
197
+ var getNotFoundError = (cmd) => Object.assign(new Error(`not found: ${cmd}`), { code: "ENOENT" });
198
+ var getPathInfo = (cmd, {
199
+ path: optPath = process.env.PATH,
200
+ pathExt: optPathExt = process.env.PATHEXT,
201
+ delimiter: optDelimiter = delimiter
202
+ }) => {
203
+ const pathEnv = cmd.match(rSlash) ? [""] : [
204
+ // windows always checks the cwd first
205
+ ...isWindows2 ? [process.cwd()] : [],
206
+ ...(optPath || /* istanbul ignore next: very unusual */
207
+ "").split(optDelimiter)
208
+ ];
209
+ if (isWindows2) {
210
+ const pathExtExe = optPathExt || [".EXE", ".CMD", ".BAT", ".COM"].join(optDelimiter);
211
+ const pathExt = pathExtExe.split(optDelimiter).flatMap((item) => [item, item.toLowerCase()]);
212
+ if (cmd.includes(".") && pathExt[0] !== "") {
213
+ pathExt.unshift("");
214
+ }
215
+ return { pathEnv, pathExt, pathExtExe };
216
+ }
217
+ return { pathEnv, pathExt: [""] };
218
+ };
219
+ var getPathPart = (raw, cmd) => {
220
+ const pathPart = /^".*"$/.test(raw) ? raw.slice(1, -1) : raw;
221
+ const prefix = !pathPart && rRel.test(cmd) ? cmd.slice(0, 2) : "";
222
+ return prefix + join7(pathPart, cmd);
223
+ };
224
+ var which2 = async (cmd, opt = {}) => {
225
+ const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt);
226
+ const found = [];
227
+ for (const envPart of pathEnv) {
228
+ const p = getPathPart(envPart, cmd);
229
+ for (const ext of pathExt) {
230
+ const withExt = p + ext;
231
+ const is = await isexe(withExt, { pathExt: pathExtExe, ignoreErrors: true });
232
+ if (is) {
233
+ if (!opt.all) {
234
+ return withExt;
235
+ }
236
+ found.push(withExt);
237
+ }
238
+ }
239
+ }
240
+ if (opt.all && found.length) {
241
+ return found;
242
+ }
243
+ if (opt.nothrow) {
244
+ return null;
245
+ }
246
+ throw getNotFoundError(cmd);
247
+ };
248
+ var whichSync = (cmd, opt = {}) => {
249
+ const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt);
250
+ const found = [];
251
+ for (const pathEnvPart of pathEnv) {
252
+ const p = getPathPart(pathEnvPart, cmd);
253
+ for (const ext of pathExt) {
254
+ const withExt = p + ext;
255
+ const is = isexeSync(withExt, { pathExt: pathExtExe, ignoreErrors: true });
256
+ if (is) {
257
+ if (!opt.all) {
258
+ return withExt;
259
+ }
260
+ found.push(withExt);
261
+ }
262
+ }
263
+ }
264
+ if (opt.all && found.length) {
265
+ return found;
266
+ }
267
+ if (opt.nothrow) {
268
+ return null;
269
+ }
270
+ throw getNotFoundError(cmd);
271
+ };
272
+ module2.exports = which2;
273
+ which2.sync = whichSync;
274
+ }
275
+ });
276
+
277
+ // src/index.ts
278
+ var path7 = __toESM(require("node:path"), 1);
279
+
280
+ // src/extractor.ts
281
+ var path = __toESM(require("node:path"), 1);
282
+ var PATTERNS = [
283
+ // Code block headers: ```typescript:src/index.ts or ```ts:src/index.ts
284
+ /```\w*:([^\n`]+)/g,
285
+ // Backtick paths with extension: `src/foo/bar.ts`
286
+ /`([^`\s]+\.\w{1,6})`/g,
287
+ // Action patterns: Created/Modified/Updated/Wrote/Edited/Deleted file.ts
288
+ /(?:Created|Modified|Updated|Wrote|Edited|Deleted|Reading|Writing)\s+`?([^\s`\n]+\.\w{1,6})`?/gi,
289
+ // File path in quotes: "src/file.ts" or 'src/file.ts'
290
+ /["']([^"'\s]+\.\w{1,6})["']/g
291
+ ];
292
+ function isValidFilePath(p) {
293
+ if (!p || p.length === 0) return false;
294
+ if (p.startsWith("http://") || p.startsWith("https://") || p.includes("://"))
295
+ return false;
296
+ if (/[<>|?*]/.test(p)) return false;
297
+ const ext = path.extname(p).slice(1).toLowerCase();
298
+ if (!ext) return false;
299
+ if (ext.length > 6) return false;
300
+ return true;
301
+ }
302
+ function normalizePath(filePath, cwd) {
303
+ const cleaned = filePath.trim();
304
+ if (path.isAbsolute(cleaned)) {
305
+ return path.normalize(cleaned);
306
+ }
307
+ return path.normalize(path.join(cwd, cleaned));
308
+ }
309
+ function extractFilePaths(message, cwd) {
310
+ if (!message || message.length === 0) {
311
+ return [];
312
+ }
313
+ const files = /* @__PURE__ */ new Set();
314
+ for (const pattern of PATTERNS) {
315
+ pattern.lastIndex = 0;
316
+ for (const match of message.matchAll(pattern)) {
317
+ const filePath = match[1];
318
+ if (filePath && isValidFilePath(filePath)) {
319
+ const normalized = normalizePath(filePath, cwd);
320
+ files.add(normalized);
321
+ }
322
+ }
323
+ }
324
+ return Array.from(files);
325
+ }
326
+
327
+ // src/install.ts
328
+ var fs = __toESM(require("node:fs"), 1);
329
+ var os = __toESM(require("node:os"), 1);
330
+ var path2 = __toESM(require("node:path"), 1);
331
+ var CODEX_CONFIG_PATH = path2.join(os.homedir(), ".codex", "config.toml");
332
+ function parseToml(content) {
333
+ const config = {};
334
+ const lines = content.split("\n");
335
+ let currentSection = "";
336
+ for (const line of lines) {
337
+ const trimmed = line.trim();
338
+ if (!trimmed || trimmed.startsWith("#")) {
339
+ continue;
340
+ }
341
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
342
+ currentSection = trimmed.slice(1, -1);
343
+ if (!config[currentSection]) {
344
+ config[currentSection] = {};
345
+ }
346
+ continue;
347
+ }
348
+ const equalIndex = trimmed.indexOf("=");
349
+ if (equalIndex > 0) {
350
+ const key = trimmed.slice(0, equalIndex).trim();
351
+ const value = trimmed.slice(equalIndex + 1).trim();
352
+ let parsedValue;
353
+ if (value.startsWith("[") && value.endsWith("]")) {
354
+ const arrayContent = value.slice(1, -1).trim();
355
+ if (arrayContent) {
356
+ parsedValue = arrayContent.split(",").map((item) => {
357
+ const trimmedItem = item.trim();
358
+ if (trimmedItem.startsWith('"') && trimmedItem.endsWith('"') || trimmedItem.startsWith("'") && trimmedItem.endsWith("'")) {
359
+ return trimmedItem.slice(1, -1);
360
+ }
361
+ return trimmedItem;
362
+ });
363
+ } else {
364
+ parsedValue = [];
365
+ }
366
+ } else if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
367
+ parsedValue = value.slice(1, -1);
368
+ } else if (value === "true") {
369
+ parsedValue = true;
370
+ } else if (value === "false") {
371
+ parsedValue = false;
372
+ } else if (!Number.isNaN(Number(value))) {
373
+ parsedValue = Number(value);
374
+ } else {
375
+ parsedValue = value;
376
+ }
377
+ if (currentSection) {
378
+ config[currentSection][key] = parsedValue;
379
+ } else {
380
+ config[key] = parsedValue;
381
+ }
382
+ }
383
+ }
384
+ return config;
385
+ }
386
+ function stringifyToml(config) {
387
+ const lines = [];
388
+ for (const [key, value] of Object.entries(config)) {
389
+ if (typeof value !== "object" || Array.isArray(value)) {
390
+ lines.push(`${key} = ${formatTomlValue(value)}`);
391
+ }
392
+ }
393
+ for (const [key, value] of Object.entries(config)) {
394
+ if (typeof value === "object" && !Array.isArray(value) && value !== null) {
395
+ if (lines.length > 0) {
396
+ lines.push("");
397
+ }
398
+ lines.push(`[${key}]`);
399
+ for (const [subKey, subValue] of Object.entries(
400
+ value
401
+ )) {
402
+ lines.push(`${subKey} = ${formatTomlValue(subValue)}`);
403
+ }
404
+ }
405
+ }
406
+ return `${lines.join("\n")}
407
+ `;
408
+ }
409
+ function formatTomlValue(value) {
410
+ if (Array.isArray(value)) {
411
+ const items = value.map(
412
+ (item) => typeof item === "string" ? `"${item}"` : String(item)
413
+ );
414
+ return `[${items.join(", ")}]`;
415
+ }
416
+ if (typeof value === "string") {
417
+ return `"${value}"`;
418
+ }
419
+ if (typeof value === "boolean") {
420
+ return value ? "true" : "false";
421
+ }
422
+ return String(value);
423
+ }
424
+ function normalizeNotifyValue(value) {
425
+ if (Array.isArray(value)) return value.slice();
426
+ if (typeof value === "string") return [value];
427
+ return [];
428
+ }
429
+ function hasNotifyEntry(entries, command) {
430
+ return entries.some((entry) => typeof entry === "string" && entry === command);
431
+ }
432
+ function removeNotifyEntry(entries, command) {
433
+ return entries.filter(
434
+ (entry) => !(typeof entry === "string" && entry === command)
435
+ );
436
+ }
437
+ function getPluginCommand() {
438
+ return ["codex-wakatime"];
439
+ }
440
+ function installHook() {
441
+ console.log("Installing codex-wakatime notification hook...");
442
+ const codexDir = path2.dirname(CODEX_CONFIG_PATH);
443
+ if (!fs.existsSync(codexDir)) {
444
+ fs.mkdirSync(codexDir, { recursive: true });
445
+ console.log(`Created ${codexDir}`);
446
+ }
447
+ let config = {};
448
+ if (fs.existsSync(CODEX_CONFIG_PATH)) {
449
+ try {
450
+ const content = fs.readFileSync(CODEX_CONFIG_PATH, "utf-8");
451
+ config = parseToml(content);
452
+ console.log("Found existing Codex config");
453
+ } catch {
454
+ console.warn("Could not parse existing config, creating new one");
455
+ }
456
+ }
457
+ const pluginCommand = getPluginCommand()[0];
458
+ const existingNotify = normalizeNotifyValue(config.notify);
459
+ if (hasNotifyEntry(existingNotify, pluginCommand)) {
460
+ console.log("codex-wakatime is already configured");
461
+ return;
462
+ }
463
+ config.notify = [...existingNotify, pluginCommand];
464
+ const newContent = stringifyToml(config);
465
+ fs.writeFileSync(CODEX_CONFIG_PATH, newContent);
466
+ console.log(`Updated ${CODEX_CONFIG_PATH}`);
467
+ console.log("codex-wakatime notification hook installed successfully!");
468
+ console.log("");
469
+ console.log(
470
+ "Make sure you have your WakaTime API key configured in ~/.wakatime.cfg"
471
+ );
472
+ }
473
+ function uninstallHook() {
474
+ console.log("Uninstalling codex-wakatime notification hook...");
475
+ if (!fs.existsSync(CODEX_CONFIG_PATH)) {
476
+ console.log("No Codex config found, nothing to uninstall");
477
+ return;
478
+ }
479
+ try {
480
+ const content = fs.readFileSync(CODEX_CONFIG_PATH, "utf-8");
481
+ const config = parseToml(content);
482
+ const pluginCommand = getPluginCommand()[0];
483
+ const existingNotify = normalizeNotifyValue(config.notify);
484
+ if (!hasNotifyEntry(existingNotify, pluginCommand)) {
485
+ console.log("codex-wakatime was not configured");
486
+ return;
487
+ }
488
+ const updatedNotify = removeNotifyEntry(existingNotify, pluginCommand);
489
+ if (updatedNotify.length === 0) {
490
+ delete config.notify;
491
+ } else {
492
+ config.notify = updatedNotify;
493
+ }
494
+ const newContent = stringifyToml(config);
495
+ fs.writeFileSync(CODEX_CONFIG_PATH, newContent);
496
+ console.log("codex-wakatime notification hook removed");
497
+ } catch (err) {
498
+ console.error("Error uninstalling hook:", err);
499
+ }
500
+ }
501
+
502
+ // src/logger.ts
503
+ var fs2 = __toESM(require("node:fs"), 1);
504
+ var os2 = __toESM(require("node:os"), 1);
505
+ var path3 = __toESM(require("node:path"), 1);
506
+ var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
507
+ LogLevel2[LogLevel2["DEBUG"] = 0] = "DEBUG";
508
+ LogLevel2[LogLevel2["INFO"] = 1] = "INFO";
509
+ LogLevel2[LogLevel2["WARN"] = 2] = "WARN";
510
+ LogLevel2[LogLevel2["ERROR"] = 3] = "ERROR";
511
+ return LogLevel2;
512
+ })(LogLevel || {});
513
+ var LOG_FILE = path3.join(os2.homedir(), ".wakatime", "codex.log");
514
+ var Logger = class {
515
+ level = 1 /* INFO */;
516
+ setLevel(level) {
517
+ this.level = level;
518
+ }
519
+ debug(msg) {
520
+ this.log(0 /* DEBUG */, msg);
521
+ }
522
+ info(msg) {
523
+ this.log(1 /* INFO */, msg);
524
+ }
525
+ warn(msg) {
526
+ this.log(2 /* WARN */, msg);
527
+ }
528
+ error(msg) {
529
+ this.log(3 /* ERROR */, msg);
530
+ }
531
+ warnException(err) {
532
+ const message = err instanceof Error ? err.message : String(err);
533
+ this.warn(message);
534
+ }
535
+ errorException(err) {
536
+ const message = err instanceof Error ? err.message : String(err);
537
+ this.error(message);
538
+ }
539
+ log(level, msg) {
540
+ if (level < this.level) return;
541
+ const levelName = LogLevel[level];
542
+ const timestamp2 = (/* @__PURE__ */ new Date()).toISOString();
543
+ const line = `[${timestamp2}][${levelName}] ${msg}
544
+ `;
545
+ try {
546
+ const dir = path3.dirname(LOG_FILE);
547
+ if (!fs2.existsSync(dir)) {
548
+ fs2.mkdirSync(dir, { recursive: true });
549
+ }
550
+ fs2.appendFileSync(LOG_FILE, line);
551
+ } catch {
552
+ }
553
+ }
554
+ };
555
+ var logger = new Logger();
556
+
557
+ // src/options.ts
558
+ var fs3 = __toESM(require("node:fs"), 1);
559
+ var os3 = __toESM(require("node:os"), 1);
560
+ var path4 = __toESM(require("node:path"), 1);
561
+ var WAKATIME_CONFIG = path4.join(os3.homedir(), ".wakatime.cfg");
562
+ function parseIni(content) {
563
+ const config = {};
564
+ const lines = content.split("\n");
565
+ for (const line of lines) {
566
+ const trimmed = line.trim();
567
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("[")) {
568
+ continue;
569
+ }
570
+ const equalIndex = trimmed.indexOf("=");
571
+ if (equalIndex > 0) {
572
+ const key = trimmed.slice(0, equalIndex).trim();
573
+ const value = trimmed.slice(equalIndex + 1).trim();
574
+ if (value.toLowerCase() === "true") {
575
+ config[key] = true;
576
+ } else if (value.toLowerCase() === "false") {
577
+ config[key] = false;
578
+ } else {
579
+ config[key] = value;
580
+ }
581
+ }
582
+ }
583
+ return config;
584
+ }
585
+ function getWakaTimeConfig() {
586
+ try {
587
+ if (fs3.existsSync(WAKATIME_CONFIG)) {
588
+ const content = fs3.readFileSync(WAKATIME_CONFIG, "utf-8");
589
+ return parseIni(content);
590
+ }
591
+ } catch {
592
+ }
593
+ return {};
594
+ }
595
+ function isDebugEnabled() {
596
+ const config = getWakaTimeConfig();
597
+ return config.debug === true;
598
+ }
599
+
600
+ // src/state.ts
601
+ var fs4 = __toESM(require("node:fs"), 1);
602
+ var os4 = __toESM(require("node:os"), 1);
603
+ var path5 = __toESM(require("node:path"), 1);
604
+ var STATE_FILE = path5.join(os4.homedir(), ".wakatime", "codex.json");
605
+ function readState() {
606
+ try {
607
+ const content = fs4.readFileSync(STATE_FILE, "utf-8");
608
+ return JSON.parse(content);
609
+ } catch {
610
+ return {};
611
+ }
612
+ }
613
+ function writeState(state) {
614
+ try {
615
+ const dir = path5.dirname(STATE_FILE);
616
+ if (!fs4.existsSync(dir)) {
617
+ fs4.mkdirSync(dir, { recursive: true });
618
+ }
619
+ fs4.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
620
+ } catch {
621
+ }
622
+ }
623
+ function timestamp() {
624
+ return Math.floor(Date.now() / 1e3);
625
+ }
626
+ function shouldSendHeartbeat(force = false) {
627
+ if (force) return true;
628
+ try {
629
+ const state = readState();
630
+ const lastHeartbeat = state.lastHeartbeatAt ?? 0;
631
+ return timestamp() - lastHeartbeat >= 60;
632
+ } catch {
633
+ return true;
634
+ }
635
+ }
636
+ function updateLastHeartbeat() {
637
+ writeState({ lastHeartbeatAt: timestamp() });
638
+ }
639
+
640
+ // src/wakatime.ts
641
+ var import_node_child_process = require("node:child_process");
642
+ var os6 = __toESM(require("node:os"), 1);
643
+
644
+ // src/dependencies.ts
645
+ var fs5 = __toESM(require("node:fs"), 1);
646
+ var import_node_fs = require("node:fs");
647
+ var https = __toESM(require("node:https"), 1);
648
+ var os5 = __toESM(require("node:os"), 1);
649
+ var path6 = __toESM(require("node:path"), 1);
650
+ var import_which = __toESM(require_lib(), 1);
651
+ var GITHUB_RELEASES_URL = "https://api.github.com/repos/wakatime/wakatime-cli/releases/latest";
652
+ var GITHUB_DOWNLOAD_URL = "https://github.com/wakatime/wakatime-cli/releases/latest/download";
653
+ var UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1e3;
654
+ var Dependencies = class {
655
+ resourcesLocation;
656
+ cliLocation;
657
+ stateFile;
658
+ constructor() {
659
+ this.resourcesLocation = path6.join(os5.homedir(), ".wakatime");
660
+ this.stateFile = path6.join(this.resourcesLocation, "codex-cli-state.json");
661
+ }
662
+ isWindows() {
663
+ return os5.platform() === "win32";
664
+ }
665
+ getOsName() {
666
+ const platform3 = os5.platform();
667
+ if (platform3 === "win32") return "windows";
668
+ return platform3;
669
+ }
670
+ getArchitecture() {
671
+ const arch2 = os5.arch();
672
+ if (arch2 === "x64") return "amd64";
673
+ if (arch2 === "ia32" || arch2.includes("32")) return "386";
674
+ if (arch2 === "arm64") return "arm64";
675
+ if (arch2 === "arm") return "arm";
676
+ return arch2;
677
+ }
678
+ getCliBinaryName() {
679
+ const osname = this.getOsName();
680
+ const arch2 = this.getArchitecture();
681
+ const ext = this.isWindows() ? ".exe" : "";
682
+ return `wakatime-cli-${osname}-${arch2}${ext}`;
683
+ }
684
+ getCliDownloadUrl() {
685
+ const osname = this.getOsName();
686
+ const arch2 = this.getArchitecture();
687
+ return `${GITHUB_DOWNLOAD_URL}/wakatime-cli-${osname}-${arch2}.zip`;
688
+ }
689
+ readState() {
690
+ try {
691
+ if (fs5.existsSync(this.stateFile)) {
692
+ return JSON.parse(fs5.readFileSync(this.stateFile, "utf-8"));
693
+ }
694
+ } catch {
695
+ }
696
+ return {};
697
+ }
698
+ writeState(state) {
699
+ try {
700
+ fs5.mkdirSync(path6.dirname(this.stateFile), { recursive: true });
701
+ fs5.writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
702
+ } catch {
703
+ }
704
+ }
705
+ getCliLocationGlobal() {
706
+ const binaryName = `wakatime-cli${this.isWindows() ? ".exe" : ""}`;
707
+ try {
708
+ const globalPath = import_which.default.sync(binaryName, { nothrow: true });
709
+ if (globalPath) {
710
+ logger.debug(`Found global wakatime-cli: ${globalPath}`);
711
+ return globalPath;
712
+ }
713
+ } catch {
714
+ }
715
+ return void 0;
716
+ }
717
+ getCliLocation() {
718
+ if (this.cliLocation) return this.cliLocation;
719
+ const globalCli = this.getCliLocationGlobal();
720
+ if (globalCli) {
721
+ this.cliLocation = globalCli;
722
+ return this.cliLocation;
723
+ }
724
+ const binary = this.getCliBinaryName();
725
+ this.cliLocation = path6.join(this.resourcesLocation, binary);
726
+ return this.cliLocation;
727
+ }
728
+ isCliInstalled() {
729
+ const location = this.getCliLocation();
730
+ return fs5.existsSync(location);
731
+ }
732
+ shouldCheckForUpdates() {
733
+ if (this.getCliLocationGlobal()) {
734
+ return false;
735
+ }
736
+ const state = this.readState();
737
+ if (!state.lastChecked) return true;
738
+ return Date.now() - state.lastChecked > UPDATE_CHECK_INTERVAL;
739
+ }
740
+ async checkAndInstallCli() {
741
+ if (this.getCliLocationGlobal()) {
742
+ logger.debug("Using global wakatime-cli, skipping installation check");
743
+ return;
744
+ }
745
+ if (!this.isCliInstalled()) {
746
+ logger.info("wakatime-cli not found, downloading...");
747
+ await this.installCli();
748
+ return;
749
+ }
750
+ if (this.shouldCheckForUpdates()) {
751
+ logger.debug("Checking for wakatime-cli updates...");
752
+ const latestVersion = await this.getLatestVersion();
753
+ const state = this.readState();
754
+ if (latestVersion && latestVersion !== state.version) {
755
+ logger.info(`Updating wakatime-cli to ${latestVersion}...`);
756
+ await this.installCli();
757
+ this.writeState({ lastChecked: Date.now(), version: latestVersion });
758
+ } else {
759
+ this.writeState({ ...state, lastChecked: Date.now() });
760
+ }
761
+ }
762
+ }
763
+ async getLatestVersion() {
764
+ return new Promise((resolve) => {
765
+ const options = {
766
+ headers: {
767
+ "User-Agent": "codex-wakatime"
768
+ }
769
+ };
770
+ https.get(GITHUB_RELEASES_URL, options, (res) => {
771
+ let data = "";
772
+ res.on("data", (chunk) => {
773
+ data += chunk;
774
+ });
775
+ res.on("end", () => {
776
+ try {
777
+ const json = JSON.parse(data);
778
+ resolve(json.tag_name);
779
+ } catch {
780
+ resolve(void 0);
781
+ }
782
+ });
783
+ }).on("error", () => {
784
+ resolve(void 0);
785
+ });
786
+ });
787
+ }
788
+ async installCli() {
789
+ const zipUrl = this.getCliDownloadUrl();
790
+ const zipFile = path6.join(
791
+ this.resourcesLocation,
792
+ `wakatime-cli-${Date.now()}.zip`
793
+ );
794
+ try {
795
+ fs5.mkdirSync(this.resourcesLocation, { recursive: true });
796
+ logger.debug(`Downloading wakatime-cli from ${zipUrl}`);
797
+ await this.downloadFile(zipUrl, zipFile);
798
+ logger.debug(`Extracting wakatime-cli to ${this.resourcesLocation}`);
799
+ await this.extractZip(zipFile, this.resourcesLocation);
800
+ if (!this.isWindows()) {
801
+ const cliPath = this.getCliLocation();
802
+ if (fs5.existsSync(cliPath)) {
803
+ fs5.chmodSync(cliPath, 493);
804
+ logger.debug(`Set executable permission on ${cliPath}`);
805
+ }
806
+ }
807
+ logger.info("wakatime-cli installed successfully");
808
+ } catch (err) {
809
+ logger.errorException(err);
810
+ throw err;
811
+ } finally {
812
+ try {
813
+ if (fs5.existsSync(zipFile)) {
814
+ fs5.unlinkSync(zipFile);
815
+ }
816
+ } catch {
817
+ }
818
+ }
819
+ }
820
+ downloadFile(url, dest) {
821
+ return new Promise((resolve, reject) => {
822
+ const followRedirect = (url2, redirectCount = 0) => {
823
+ if (redirectCount > 5) {
824
+ reject(new Error("Too many redirects"));
825
+ return;
826
+ }
827
+ https.get(url2, (res) => {
828
+ if (res.statusCode === 301 || res.statusCode === 302) {
829
+ const location = res.headers.location;
830
+ if (location) {
831
+ followRedirect(location, redirectCount + 1);
832
+ return;
833
+ }
834
+ }
835
+ if (res.statusCode !== 200) {
836
+ reject(new Error(`HTTP ${res.statusCode}`));
837
+ return;
838
+ }
839
+ const file = (0, import_node_fs.createWriteStream)(dest);
840
+ res.pipe(file);
841
+ file.on("finish", () => {
842
+ file.close();
843
+ resolve();
844
+ });
845
+ file.on("error", (err) => {
846
+ fs5.unlinkSync(dest);
847
+ reject(err);
848
+ });
849
+ }).on("error", reject);
850
+ };
851
+ followRedirect(url);
852
+ });
853
+ }
854
+ async extractZip(zipFile, destDir) {
855
+ const { execSync } = await import("node:child_process");
856
+ try {
857
+ if (this.isWindows()) {
858
+ execSync(
859
+ `powershell -command "Expand-Archive -Force '${zipFile}' '${destDir}'"`,
860
+ {
861
+ windowsHide: true
862
+ }
863
+ );
864
+ } else {
865
+ execSync(`unzip -o "${zipFile}" -d "${destDir}"`, {
866
+ stdio: "ignore"
867
+ });
868
+ }
869
+ } catch {
870
+ logger.warn("Native unzip failed, attempting manual extraction");
871
+ await this.extractZipManual(zipFile, destDir);
872
+ }
873
+ }
874
+ async extractZipManual(zipFile, destDir) {
875
+ const data = fs5.readFileSync(zipFile);
876
+ let offset = 0;
877
+ while (offset < data.length - 4) {
878
+ if (data[offset] === 80 && data[offset + 1] === 75 && data[offset + 2] === 3 && data[offset + 3] === 4) {
879
+ const compressedSize = data.readUInt32LE(offset + 18);
880
+ const uncompressedSize = data.readUInt32LE(offset + 22);
881
+ const fileNameLength = data.readUInt16LE(offset + 26);
882
+ const extraFieldLength = data.readUInt16LE(offset + 28);
883
+ const fileName = data.slice(offset + 30, offset + 30 + fileNameLength).toString();
884
+ const dataStart = offset + 30 + fileNameLength + extraFieldLength;
885
+ const compressionMethod = data.readUInt16LE(offset + 8);
886
+ if (fileName.includes("wakatime-cli")) {
887
+ const destPath = path6.join(destDir, path6.basename(fileName));
888
+ if (compressionMethod === 0) {
889
+ const fileData = data.slice(
890
+ dataStart,
891
+ dataStart + uncompressedSize
892
+ );
893
+ fs5.writeFileSync(destPath, fileData);
894
+ } else if (compressionMethod === 8) {
895
+ const { inflateRawSync } = await import("node:zlib");
896
+ const compressedData = data.slice(
897
+ dataStart,
898
+ dataStart + compressedSize
899
+ );
900
+ const decompressed = inflateRawSync(compressedData);
901
+ fs5.writeFileSync(destPath, decompressed);
902
+ }
903
+ logger.debug(`Extracted ${fileName} to ${destPath}`);
904
+ return;
905
+ }
906
+ offset = dataStart + compressedSize;
907
+ } else {
908
+ offset++;
909
+ }
910
+ }
911
+ throw new Error("Could not find wakatime-cli in zip file");
912
+ }
913
+ };
914
+ var dependencies = new Dependencies();
915
+
916
+ // src/version.ts
917
+ var VERSION = "1.0.0";
918
+
919
+ // src/wakatime.ts
920
+ function isWindows() {
921
+ return os6.platform() === "win32";
922
+ }
923
+ function buildExecOptions() {
924
+ const options = {
925
+ windowsHide: true,
926
+ timeout: 3e4
927
+ };
928
+ if (!isWindows() && !process.env.WAKATIME_HOME && !process.env.HOME) {
929
+ options.env = { ...process.env, WAKATIME_HOME: os6.homedir() };
930
+ }
931
+ return options;
932
+ }
933
+ function formatArgs(args) {
934
+ return args.map((arg) => {
935
+ if (arg.includes(" ")) {
936
+ return `"${arg.replace(/"/g, '\\"')}"`;
937
+ }
938
+ return arg;
939
+ }).join(" ");
940
+ }
941
+ async function ensureCliInstalled() {
942
+ try {
943
+ await dependencies.checkAndInstallCli();
944
+ return dependencies.isCliInstalled();
945
+ } catch (err) {
946
+ logger.errorException(err);
947
+ return false;
948
+ }
949
+ }
950
+ function sendHeartbeat(params) {
951
+ const cliLocation = dependencies.getCliLocation();
952
+ if (!dependencies.isCliInstalled()) {
953
+ logger.warn("wakatime-cli not installed, skipping heartbeat");
954
+ return;
955
+ }
956
+ const args = [
957
+ "--entity",
958
+ params.entity,
959
+ "--entity-type",
960
+ params.entityType,
961
+ "--category",
962
+ params.category ?? "ai coding",
963
+ "--plugin",
964
+ `codex/1.0.0 codex-wakatime/${VERSION}`
965
+ ];
966
+ if (params.projectFolder) {
967
+ args.push("--project-folder", params.projectFolder);
968
+ }
969
+ if (params.project) {
970
+ args.push("--project", params.project);
971
+ }
972
+ if (params.lineChanges !== void 0 && params.lineChanges !== 0) {
973
+ args.push("--ai-line-changes", params.lineChanges.toString());
974
+ }
975
+ if (params.isWrite) {
976
+ args.push("--write");
977
+ }
978
+ logger.debug(`Sending heartbeat: wakatime-cli ${formatArgs(args)}`);
979
+ const execOptions = buildExecOptions();
980
+ (0, import_node_child_process.execFile)(cliLocation, args, execOptions, (error, stdout, stderr) => {
981
+ const output = (stdout?.toString().trim() ?? "") + (stderr?.toString().trim() ?? "");
982
+ if (output) {
983
+ logger.debug(`wakatime-cli output: ${output}`);
984
+ }
985
+ if (error) {
986
+ logger.error(`wakatime-cli error: ${error.message}`);
987
+ }
988
+ });
989
+ }
990
+
991
+ // src/index.ts
992
+ function parseNotification() {
993
+ const jsonArg = process.argv[2];
994
+ if (!jsonArg) {
995
+ return void 0;
996
+ }
997
+ if (jsonArg.startsWith("-")) {
998
+ return void 0;
999
+ }
1000
+ try {
1001
+ const notification = JSON.parse(jsonArg);
1002
+ return notification;
1003
+ } catch (err) {
1004
+ logger.warnException(err);
1005
+ return void 0;
1006
+ }
1007
+ }
1008
+ async function main() {
1009
+ const args = process.argv.slice(2);
1010
+ if (args.includes("--install")) {
1011
+ installHook();
1012
+ return;
1013
+ }
1014
+ if (args.includes("--uninstall")) {
1015
+ uninstallHook();
1016
+ return;
1017
+ }
1018
+ if (isDebugEnabled()) {
1019
+ logger.setLevel(0 /* DEBUG */);
1020
+ }
1021
+ logger.debug("codex-wakatime started");
1022
+ const notification = parseNotification();
1023
+ if (!notification) {
1024
+ logger.debug("No valid notification received");
1025
+ return;
1026
+ }
1027
+ logger.debug(`Received notification: ${notification.type}`);
1028
+ if (notification.type !== "agent-turn-complete") {
1029
+ logger.debug(`Ignoring notification type: ${notification.type}`);
1030
+ return;
1031
+ }
1032
+ if (!shouldSendHeartbeat()) {
1033
+ logger.debug("Skipping heartbeat due to rate limiting");
1034
+ return;
1035
+ }
1036
+ const cliAvailable = await ensureCliInstalled();
1037
+ if (!cliAvailable) {
1038
+ logger.warn("wakatime-cli not available, skipping heartbeat");
1039
+ return;
1040
+ }
1041
+ const assistantMessage = notification["last-assistant-message"] ?? "";
1042
+ const cwd = notification.cwd;
1043
+ const files = extractFilePaths(assistantMessage, cwd);
1044
+ logger.debug(`Extracted ${files.length} files from message`);
1045
+ if (files.length > 0) {
1046
+ for (const file of files) {
1047
+ logger.debug(`Sending heartbeat for file: ${file}`);
1048
+ sendHeartbeat({
1049
+ entity: file,
1050
+ entityType: "file",
1051
+ category: "ai coding",
1052
+ projectFolder: cwd
1053
+ });
1054
+ }
1055
+ } else {
1056
+ logger.debug(`Sending project heartbeat for: ${cwd}`);
1057
+ sendHeartbeat({
1058
+ entity: cwd,
1059
+ entityType: "app",
1060
+ category: "ai coding",
1061
+ project: path7.basename(cwd)
1062
+ });
1063
+ }
1064
+ updateLastHeartbeat();
1065
+ logger.debug("codex-wakatime completed");
1066
+ }
1067
+ main().catch((err) => {
1068
+ logger.errorException(err);
1069
+ process.exit(1);
1070
+ });
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "codex-wakatime",
4
+ "version": "1.0.0",
5
+ "description": "WakaTime plugin for OpenAI Codex CLI - Track AI coding activity and time spent",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/wakatime/codex-wakatime.git"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.cjs",
12
+ "types": "dist/index.d.ts",
13
+ "bin": {
14
+ "codex-wakatime": "dist/index.cjs"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "prebuild": "node scripts/generate-version.js",
21
+ "build": "npm run prebuild && esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/index.cjs --banner:js=\"#!/usr/bin/env node\"",
22
+ "typecheck": "tsc --noEmit",
23
+ "lint": "biome lint .",
24
+ "format": "biome format --write .",
25
+ "check": "biome check .",
26
+ "test": "vitest",
27
+ "test:run": "vitest run",
28
+ "test:coverage": "vitest run --coverage",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "keywords": [
32
+ "codex",
33
+ "openai",
34
+ "wakatime",
35
+ "time-tracking",
36
+ "ai",
37
+ "coding",
38
+ "productivity"
39
+ ],
40
+ "author": "WakaTime",
41
+ "license": "MIT",
42
+ "devDependencies": {
43
+ "@biomejs/biome": "^2.3.10",
44
+ "@types/node": "^22.0.0",
45
+ "@types/which": "^3.0.0",
46
+ "@vitest/coverage-v8": "^4.0.16",
47
+ "esbuild": "^0.25.0",
48
+ "typescript": "^5.0.0",
49
+ "vitest": "^4.0.16"
50
+ },
51
+ "dependencies": {
52
+ "@iarna/toml": "^3.0.0",
53
+ "which": "^4.0.0"
54
+ }
55
+ }