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.
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/index.cjs +1070 -0
- 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
|
+
}
|