kimi-wakatime 0.1.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 +169 -0
- package/package.json +23 -0
- package/src/extractor.js +21 -0
- package/src/index.js +123 -0
- package/src/install.js +59 -0
- package/src/logger.js +33 -0
- package/src/state.js +50 -0
- package/src/wakatime.js +114 -0
- package/test/extractor.test.js +32 -0
- package/test/install.test.js +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DanielWTE
|
|
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,169 @@
|
|
|
1
|
+
# kimi-wakatime
|
|
2
|
+
|
|
3
|
+
WakaTime integration for [Kimi Code CLI](https://www.kimi.com/code/). Track AI coding activity and time spent.
|
|
4
|
+
|
|
5
|
+
Inspired by [codex-wakatime](https://github.com/angristan/codex-wakatime).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Automatic time tracking for Kimi Code CLI sessions
|
|
10
|
+
- File-level write heartbeats when Kimi edits or creates files (`Edit`/`Write` tools)
|
|
11
|
+
- Project-level heartbeats on prompts and completed turns
|
|
12
|
+
- Heartbeats categorized as **AI coding** in your WakaTime dashboard
|
|
13
|
+
- 60-second heartbeat rate limiting
|
|
14
|
+
- Automatic `wakatime-cli` installation if missing
|
|
15
|
+
- Zero dependencies, plain Node.js (>= 18)
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
1. A [WakaTime](https://wakatime.com) account and API key
|
|
20
|
+
2. API key configured in `~/.wakatime.cfg`:
|
|
21
|
+
|
|
22
|
+
```ini
|
|
23
|
+
[settings]
|
|
24
|
+
api_key = your-api-key-here
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. [Kimi Code CLI](https://www.kimi.com/code/) installed
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# Install the package
|
|
33
|
+
npm install -g kimi-wakatime
|
|
34
|
+
|
|
35
|
+
# Register the Kimi Code hooks
|
|
36
|
+
kimi-wakatime --install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This appends `[[hooks]]` entries to `~/.kimi-code/config.toml`. Start a new
|
|
40
|
+
Kimi Code session to activate them.
|
|
41
|
+
|
|
42
|
+
## How It Works
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
46
|
+
│ Kimi Code CLI Session │
|
|
47
|
+
└─────────────────────────┬───────────────────────────────────┘
|
|
48
|
+
│
|
|
49
|
+
▼
|
|
50
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
51
|
+
│ Hooks (configured in ~/.kimi-code/config.toml) │
|
|
52
|
+
│ - UserPromptSubmit → project-level heartbeat │
|
|
53
|
+
│ - PostToolUse (Edit|Write) → file-level write heartbeat │
|
|
54
|
+
│ - Stop → project-level heartbeat │
|
|
55
|
+
│ Payload arrives as JSON on stdin: │
|
|
56
|
+
│ { hook_event_name, session_id, cwd, tool_input, ... } │
|
|
57
|
+
└─────────────────────────┬───────────────────────────────────┘
|
|
58
|
+
│
|
|
59
|
+
▼
|
|
60
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
61
|
+
│ kimi-wakatime │
|
|
62
|
+
│ 1. Parse hook JSON from stdin │
|
|
63
|
+
│ 2. Extract file path from Edit/Write tool input │
|
|
64
|
+
│ 3. Check 60-second rate limit │
|
|
65
|
+
│ 4. Spawn wakatime-cli detached (never blocks the session) │
|
|
66
|
+
└─────────────────────────┬───────────────────────────────────┘
|
|
67
|
+
│
|
|
68
|
+
▼
|
|
69
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
70
|
+
│ WakaTime Dashboard │
|
|
71
|
+
│ Time appears under category "AI coding", │
|
|
72
|
+
│ editor "kimi-wakatime" │
|
|
73
|
+
└─────────────────────────────────────────────────────────────┘
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Installed hook configuration
|
|
77
|
+
|
|
78
|
+
`kimi-wakatime --install` appends this to `~/.kimi-code/config.toml`:
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
# >>> kimi-wakatime >>>
|
|
82
|
+
[[hooks]]
|
|
83
|
+
event = "PostToolUse"
|
|
84
|
+
matcher = "^(Edit|Write)$"
|
|
85
|
+
command = "kimi-wakatime --hook"
|
|
86
|
+
timeout = 15
|
|
87
|
+
|
|
88
|
+
[[hooks]]
|
|
89
|
+
event = "Stop"
|
|
90
|
+
command = "kimi-wakatime --hook"
|
|
91
|
+
timeout = 15
|
|
92
|
+
|
|
93
|
+
[[hooks]]
|
|
94
|
+
event = "UserPromptSubmit"
|
|
95
|
+
command = "kimi-wakatime --hook"
|
|
96
|
+
timeout = 15
|
|
97
|
+
# <<< kimi-wakatime <<<
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The marker comments make `--uninstall` and re-installs safe and idempotent.
|
|
101
|
+
|
|
102
|
+
## Commands
|
|
103
|
+
|
|
104
|
+
| Command | Description |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `kimi-wakatime --install` | Add hooks to `~/.kimi-code/config.toml` |
|
|
107
|
+
| `kimi-wakatime --uninstall` | Remove hooks from `~/.kimi-code/config.toml` |
|
|
108
|
+
| `kimi-wakatime --hook` | Process a hook payload from stdin (called by Kimi Code) |
|
|
109
|
+
| `kimi-wakatime --version` | Print version |
|
|
110
|
+
| `kimi-wakatime --help` | Show help |
|
|
111
|
+
|
|
112
|
+
## Files & Locations
|
|
113
|
+
|
|
114
|
+
| File | Purpose |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `~/.wakatime/kimi.json` | Rate limiting state |
|
|
117
|
+
| `~/.wakatime/kimi.log` | Debug logs |
|
|
118
|
+
| `~/.kimi-code/config.toml` | Kimi Code configuration |
|
|
119
|
+
| `~/.wakatime.cfg` | WakaTime API key and settings |
|
|
120
|
+
| `~/.wakatime/wakatime-cli-*` | Auto-downloaded wakatime-cli (if not already in PATH) |
|
|
121
|
+
|
|
122
|
+
## Debug Mode
|
|
123
|
+
|
|
124
|
+
Enable debug logging by adding to `~/.wakatime.cfg`:
|
|
125
|
+
|
|
126
|
+
```ini
|
|
127
|
+
[settings]
|
|
128
|
+
debug = true
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Logs are written to `~/.wakatime/kimi.log`. wakatime-cli's own log is at
|
|
132
|
+
`~/.wakatime/wakatime.log` (pass `--verbose` there for more detail).
|
|
133
|
+
|
|
134
|
+
## Troubleshooting
|
|
135
|
+
|
|
136
|
+
### No heartbeats being sent
|
|
137
|
+
|
|
138
|
+
1. Check that your API key is configured in `~/.wakatime.cfg`
|
|
139
|
+
2. Verify the hooks are present in `~/.kimi-code/config.toml` and you started a **new** session after installing
|
|
140
|
+
3. Enable debug mode and check `~/.wakatime/kimi.log`
|
|
141
|
+
4. Confirm heartbeats arrive on the [plugin status page](https://wakatime.com/plugins/status)
|
|
142
|
+
|
|
143
|
+
### Rate limiting
|
|
144
|
+
|
|
145
|
+
Heartbeats for the same entity are limited to one per 60 seconds (write events
|
|
146
|
+
always go through). If you're testing, wait at least 60 seconds between turns.
|
|
147
|
+
|
|
148
|
+
### wakatime-cli not found
|
|
149
|
+
|
|
150
|
+
The plugin automatically downloads `wakatime-cli` from
|
|
151
|
+
[GitHub releases](https://github.com/wakatime/wakatime-cli/releases) if it's
|
|
152
|
+
not in your PATH or `~/.wakatime/`. If that fails, install it manually.
|
|
153
|
+
|
|
154
|
+
## Uninstall
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
kimi-wakatime --uninstall
|
|
158
|
+
npm uninstall -g kimi-wakatime
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
npm test # run tests (node:test, no dependencies)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kimi-wakatime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WakaTime integration for Kimi Code CLI - track AI coding activity and time spent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kimi-wakatime": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test 'test/*.test.js'"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"wakatime",
|
|
17
|
+
"kimi",
|
|
18
|
+
"kimi-code",
|
|
19
|
+
"time-tracking",
|
|
20
|
+
"ai-coding"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT"
|
|
23
|
+
}
|
package/src/extractor.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Extract file paths from Kimi Code hook payloads.
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Tool input fields that may carry the target file path,
|
|
5
|
+
// depending on the tool (Edit/Write use `path`).
|
|
6
|
+
const PATH_FIELDS = ['path', 'file_path', 'filePath', 'target_file', 'filename'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract the edited/written file path from a PostToolUse tool_input object.
|
|
10
|
+
* Returns an absolute path, or null if no path was found.
|
|
11
|
+
*/
|
|
12
|
+
export function extractFilePath(toolInput, cwd) {
|
|
13
|
+
if (!toolInput || typeof toolInput !== 'object') return null;
|
|
14
|
+
for (const field of PATH_FIELDS) {
|
|
15
|
+
const value = toolInput[field];
|
|
16
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
17
|
+
return isAbsolute(value) ? value : resolve(cwd, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// kimi-wakatime: WakaTime integration for Kimi Code CLI
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { install, uninstall } from './install.js';
|
|
7
|
+
import { extractFilePath } from './extractor.js';
|
|
8
|
+
import { shouldSend, markSent } from './state.js';
|
|
9
|
+
import { ensureCli, sendHeartbeat } from './wakatime.js';
|
|
10
|
+
import { log } from './logger.js';
|
|
11
|
+
|
|
12
|
+
const pkg = JSON.parse(
|
|
13
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
|
14
|
+
);
|
|
15
|
+
const PLUGIN_ID = `kimi-wakatime/${pkg.version}`;
|
|
16
|
+
|
|
17
|
+
const HELP = `kimi-wakatime v${pkg.version} - WakaTime integration for Kimi Code CLI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
kimi-wakatime --install Add WakaTime hooks to ~/.kimi-code/config.toml
|
|
21
|
+
kimi-wakatime --uninstall Remove the hooks from ~/.kimi-code/config.toml
|
|
22
|
+
kimi-wakatime --hook Process a Kimi Code hook payload from stdin
|
|
23
|
+
kimi-wakatime --version Print version
|
|
24
|
+
kimi-wakatime --help Show this help
|
|
25
|
+
|
|
26
|
+
Prerequisites: a WakaTime API key in ~/.wakatime.cfg
|
|
27
|
+
[settings]
|
|
28
|
+
api_key = your-api-key-here
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
function readStdin() {
|
|
32
|
+
return new Promise((resolveP) => {
|
|
33
|
+
let data = '';
|
|
34
|
+
process.stdin.setEncoding('utf8');
|
|
35
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
36
|
+
process.stdin.on('end', () => resolveP(data));
|
|
37
|
+
// no stdin (e.g. run manually without a pipe): bail out quickly
|
|
38
|
+
if (process.stdin.isTTY) resolveP('');
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handleHook() {
|
|
43
|
+
const raw = await readStdin();
|
|
44
|
+
let payload = {};
|
|
45
|
+
try {
|
|
46
|
+
payload = JSON.parse(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
log('hook invoked with unparseable payload, ignoring');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const event = payload.hook_event_name ?? '';
|
|
53
|
+
const cwd = payload.cwd || process.cwd();
|
|
54
|
+
log('hook event:', event, 'cwd:', cwd);
|
|
55
|
+
|
|
56
|
+
let entity;
|
|
57
|
+
let isWrite = false;
|
|
58
|
+
|
|
59
|
+
if (event === 'PostToolUse') {
|
|
60
|
+
// Edit/Write tools: file-level write heartbeat
|
|
61
|
+
const file = extractFilePath(payload.tool_input, cwd);
|
|
62
|
+
if (!file) {
|
|
63
|
+
log('no file path in tool_input, skipping');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
entity = file;
|
|
67
|
+
isWrite = true;
|
|
68
|
+
} else {
|
|
69
|
+
// Stop / UserPromptSubmit / anything else: project-level heartbeat
|
|
70
|
+
entity = cwd;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!shouldSend(entity, { isWrite })) {
|
|
74
|
+
log('rate limited, skipping:', entity);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
markSent(entity);
|
|
78
|
+
|
|
79
|
+
const cli = await ensureCli();
|
|
80
|
+
sendHeartbeat({ cli, entity, cwd, isWrite, plugin: PLUGIN_ID });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
const arg = process.argv[2];
|
|
85
|
+
try {
|
|
86
|
+
switch (arg) {
|
|
87
|
+
case '--install': {
|
|
88
|
+
const file = install();
|
|
89
|
+
console.log(`WakaTime hooks installed into ${file}`);
|
|
90
|
+
console.log('Start a new Kimi Code session to activate them.');
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case '--uninstall': {
|
|
94
|
+
const removed = uninstall();
|
|
95
|
+
console.log(removed ? 'WakaTime hooks removed from ~/.kimi-code/config.toml' : 'No kimi-wakatime hooks found.');
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case '--hook':
|
|
99
|
+
await handleHook();
|
|
100
|
+
break;
|
|
101
|
+
case '--version':
|
|
102
|
+
console.log(pkg.version);
|
|
103
|
+
break;
|
|
104
|
+
case '--help':
|
|
105
|
+
case undefined:
|
|
106
|
+
console.log(HELP);
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
console.error(`Unknown argument: ${arg}\n`);
|
|
110
|
+
console.log(HELP);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// Hooks must fail open: never break a Kimi Code session.
|
|
115
|
+
log('error:', err?.stack ?? err);
|
|
116
|
+
if (arg !== '--hook') {
|
|
117
|
+
console.error(`Error: ${err?.message ?? err}`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main();
|
package/src/install.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Install/uninstall hooks in ~/.kimi-code/config.toml
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILE = join(homedir(), '.kimi-code', 'config.toml');
|
|
7
|
+
const START_MARK = '# >>> kimi-wakatime >>>';
|
|
8
|
+
const END_MARK = '# <<< kimi-wakatime <<<';
|
|
9
|
+
|
|
10
|
+
const HOOKS_BLOCK = `
|
|
11
|
+
${START_MARK}
|
|
12
|
+
[[hooks]]
|
|
13
|
+
event = "PostToolUse"
|
|
14
|
+
matcher = "^(Edit|Write)$"
|
|
15
|
+
command = "kimi-wakatime --hook"
|
|
16
|
+
timeout = 15
|
|
17
|
+
|
|
18
|
+
[[hooks]]
|
|
19
|
+
event = "Stop"
|
|
20
|
+
command = "kimi-wakatime --hook"
|
|
21
|
+
timeout = 15
|
|
22
|
+
|
|
23
|
+
[[hooks]]
|
|
24
|
+
event = "UserPromptSubmit"
|
|
25
|
+
command = "kimi-wakatime --hook"
|
|
26
|
+
timeout = 15
|
|
27
|
+
${END_MARK}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function readConfig() {
|
|
31
|
+
return existsSync(CONFIG_FILE) ? readFileSync(CONFIG_FILE, 'utf8') : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function install() {
|
|
35
|
+
let config = readConfig();
|
|
36
|
+
if (config.includes(START_MARK)) {
|
|
37
|
+
// already installed: replace old block with current version
|
|
38
|
+
config = removeBlock(config);
|
|
39
|
+
}
|
|
40
|
+
mkdirSync(dirname(CONFIG_FILE), { recursive: true });
|
|
41
|
+
const separator = config.length > 0 && !config.endsWith('\n') ? '\n' : '';
|
|
42
|
+
writeFileSync(CONFIG_FILE, config + separator + HOOKS_BLOCK);
|
|
43
|
+
return CONFIG_FILE;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function removeBlock(config) {
|
|
47
|
+
const start = config.indexOf(START_MARK);
|
|
48
|
+
const end = config.indexOf(END_MARK);
|
|
49
|
+
if (start === -1 || end === -1 || end < start) return config;
|
|
50
|
+
return (config.slice(0, start) + config.slice(end + END_MARK.length)).replace(/\n{3,}/g, '\n\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function uninstall() {
|
|
54
|
+
const config = readConfig();
|
|
55
|
+
const cleaned = removeBlock(config);
|
|
56
|
+
if (cleaned === config) return false;
|
|
57
|
+
writeFileSync(CONFIG_FILE, cleaned);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Debug logging to ~/.wakatime/kimi.log, enabled via `debug = true` in ~/.wakatime.cfg
|
|
2
|
+
import { appendFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const WAKATIME_DIR = join(homedir(), '.wakatime');
|
|
7
|
+
const LOG_FILE = join(WAKATIME_DIR, 'kimi.log');
|
|
8
|
+
const CONFIG_FILE = join(homedir(), '.wakatime.cfg');
|
|
9
|
+
|
|
10
|
+
let debugEnabled = null;
|
|
11
|
+
|
|
12
|
+
function isDebug() {
|
|
13
|
+
if (debugEnabled === null) {
|
|
14
|
+
try {
|
|
15
|
+
const cfg = readFileSync(CONFIG_FILE, 'utf8');
|
|
16
|
+
debugEnabled = /^\s*debug\s*=\s*true\s*$/im.test(cfg);
|
|
17
|
+
} catch {
|
|
18
|
+
debugEnabled = false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return debugEnabled;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function log(...args) {
|
|
25
|
+
if (!isDebug()) return;
|
|
26
|
+
try {
|
|
27
|
+
mkdirSync(WAKATIME_DIR, { recursive: true });
|
|
28
|
+
const line = `${new Date().toISOString()} ${args.map(String).join(' ')}\n`;
|
|
29
|
+
appendFileSync(LOG_FILE, line);
|
|
30
|
+
} catch {
|
|
31
|
+
// never break the hook because of logging
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Rate limiting state: at most one heartbeat per entity per 60 seconds.
|
|
2
|
+
// State lives in ~/.wakatime/kimi.json
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const STATE_FILE = join(homedir(), '.wakatime', 'kimi.json');
|
|
8
|
+
export const RATE_LIMIT_SECONDS = 60;
|
|
9
|
+
|
|
10
|
+
function loadState() {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function saveState(state) {
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(join(homedir(), '.wakatime'), { recursive: true });
|
|
21
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore: rate limiting is best-effort
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decide whether a heartbeat for `entity` should be sent now.
|
|
29
|
+
* Write events always pass. Otherwise skip if the same entity was
|
|
30
|
+
* sent less than RATE_LIMIT_SECONDS ago.
|
|
31
|
+
*/
|
|
32
|
+
export function shouldSend(entity, { isWrite = false, now = Date.now() } = {}) {
|
|
33
|
+
if (isWrite) return true;
|
|
34
|
+
const state = loadState();
|
|
35
|
+
const last = state[entity];
|
|
36
|
+
return !last || now - last >= RATE_LIMIT_SECONDS * 1000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function markSent(entity, now = Date.now()) {
|
|
40
|
+
const state = loadState();
|
|
41
|
+
state[entity] = now;
|
|
42
|
+
// keep the file small
|
|
43
|
+
const keys = Object.keys(state);
|
|
44
|
+
if (keys.length > 500) {
|
|
45
|
+
for (const k of keys.sort((a, b) => state[a] - state[b]).slice(0, keys.length - 500)) {
|
|
46
|
+
delete state[k];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
saveState(state);
|
|
50
|
+
}
|
package/src/wakatime.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// wakatime-cli discovery, installation and invocation.
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readdirSync, mkdirSync, chmodSync, createWriteStream, unlinkSync } from 'node:fs';
|
|
4
|
+
import { homedir, platform, arch } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
|
|
8
|
+
const WAKATIME_DIR = join(homedir(), '.wakatime');
|
|
9
|
+
const RELEASE_API = 'https://api.github.com/repos/wakatime/wakatime-cli/releases/latest';
|
|
10
|
+
|
|
11
|
+
function isWindows() {
|
|
12
|
+
return platform() === 'win32';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function run(cmd, args) {
|
|
16
|
+
const r = spawnSync(cmd, args, { stdio: 'ignore', timeout: 10_000 });
|
|
17
|
+
return r.status === 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Locate a usable wakatime-cli binary, or null. */
|
|
21
|
+
export function findCli() {
|
|
22
|
+
// 1. PATH
|
|
23
|
+
if (run('wakatime-cli', ['--version'])) return 'wakatime-cli';
|
|
24
|
+
|
|
25
|
+
// 2. ~/.wakatime/wakatime-cli* (layout used by all official plugins)
|
|
26
|
+
try {
|
|
27
|
+
const entries = readdirSync(WAKATIME_DIR);
|
|
28
|
+
const candidate = entries.find((e) => {
|
|
29
|
+
if (!e.startsWith('wakatime-cli')) return false;
|
|
30
|
+
if (isWindows()) return e.endsWith('.exe');
|
|
31
|
+
return !e.includes('.') || e.startsWith(`wakatime-cli-${platform()}`);
|
|
32
|
+
});
|
|
33
|
+
if (candidate) {
|
|
34
|
+
const full = join(WAKATIME_DIR, candidate);
|
|
35
|
+
if (run(full, ['--version'])) return full;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// dir does not exist yet
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function download(url, dest) {
|
|
44
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
45
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
|
|
46
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
47
|
+
const w = createWriteStream(dest);
|
|
48
|
+
w.write(buf);
|
|
49
|
+
await new Promise((resolveP, reject) => {
|
|
50
|
+
w.end(resolveP);
|
|
51
|
+
w.on('error', reject);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Download the latest wakatime-cli release into ~/.wakatime and return its path. */
|
|
56
|
+
export async function installCli() {
|
|
57
|
+
const osName = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[platform()];
|
|
58
|
+
if (!osName) throw new Error(`unsupported platform: ${platform()}`);
|
|
59
|
+
const archName = { x64: 'amd64', arm64: 'arm64' }[arch()] ?? 'amd64';
|
|
60
|
+
|
|
61
|
+
const res = await fetch(RELEASE_API, {
|
|
62
|
+
headers: { 'User-Agent': 'kimi-wakatime' },
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) throw new Error(`GitHub API failed: HTTP ${res.status}`);
|
|
65
|
+
const release = await res.json();
|
|
66
|
+
const tag = release.tag_name; // e.g. v1.115.0
|
|
67
|
+
const name = `wakatime-cli-${osName}-${archName}`;
|
|
68
|
+
const url = `https://github.com/wakatime/wakatime-cli/releases/download/${tag}/${name}.zip`;
|
|
69
|
+
|
|
70
|
+
mkdirSync(WAKATIME_DIR, { recursive: true });
|
|
71
|
+
const zipPath = join(WAKATIME_DIR, `${name}.zip`);
|
|
72
|
+
log('downloading', url);
|
|
73
|
+
await download(url, zipPath);
|
|
74
|
+
|
|
75
|
+
// bsdtar (ships with macOS, Linux and Windows 10+) extracts zips fine
|
|
76
|
+
const r = spawnSync('tar', ['-xf', zipPath, '-C', WAKATIME_DIR], { stdio: 'ignore' });
|
|
77
|
+
unlinkSync(zipPath);
|
|
78
|
+
if (r.status !== 0) throw new Error('failed to extract wakatime-cli zip');
|
|
79
|
+
|
|
80
|
+
const binName = isWindows() ? `${name}.exe` : name;
|
|
81
|
+
const binPath = join(WAKATIME_DIR, binName);
|
|
82
|
+
if (!existsSync(binPath)) throw new Error(`expected binary missing after extract: ${binPath}`);
|
|
83
|
+
if (!isWindows()) chmodSync(binPath, 0o755);
|
|
84
|
+
return binPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolve a usable wakatime-cli, downloading it if necessary. */
|
|
88
|
+
export async function ensureCli() {
|
|
89
|
+
const existing = findCli();
|
|
90
|
+
if (existing) return existing;
|
|
91
|
+
log('wakatime-cli not found, installing');
|
|
92
|
+
return installCli();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Send a heartbeat. Spawns wakatime-cli detached so the hook returns
|
|
97
|
+
* immediately and never blocks the Kimi Code session.
|
|
98
|
+
*/
|
|
99
|
+
export function sendHeartbeat({ cli, entity, cwd, isWrite, plugin }) {
|
|
100
|
+
const args = [
|
|
101
|
+
'--entity', entity,
|
|
102
|
+
'--plugin', plugin,
|
|
103
|
+
'--category', 'ai coding',
|
|
104
|
+
'--alternate-project', cwd.split(/[\\/]/).filter(Boolean).pop() || 'unknown',
|
|
105
|
+
];
|
|
106
|
+
if (isWrite) args.push('--write');
|
|
107
|
+
log('heartbeat:', cli, args.join(' '));
|
|
108
|
+
const child = spawn(cli, args, {
|
|
109
|
+
cwd,
|
|
110
|
+
detached: true,
|
|
111
|
+
stdio: 'ignore',
|
|
112
|
+
});
|
|
113
|
+
child.unref();
|
|
114
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { extractFilePath } from '../src/extractor.js';
|
|
5
|
+
|
|
6
|
+
const cwd = '/Users/test/project';
|
|
7
|
+
|
|
8
|
+
test('extracts absolute path as-is', () => {
|
|
9
|
+
assert.equal(
|
|
10
|
+
extractFilePath({ path: '/Users/test/project/src/a.ts' }, cwd),
|
|
11
|
+
'/Users/test/project/src/a.ts',
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('resolves relative path against cwd', () => {
|
|
16
|
+
assert.equal(
|
|
17
|
+
extractFilePath({ path: 'src/a.ts' }, cwd),
|
|
18
|
+
resolve(cwd, 'src/a.ts'),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('supports alternative field names', () => {
|
|
23
|
+
assert.equal(extractFilePath({ file_path: 'a.ts' }, cwd), resolve(cwd, 'a.ts'));
|
|
24
|
+
assert.equal(extractFilePath({ filePath: 'b.ts' }, cwd), resolve(cwd, 'b.ts'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns null when no path is present', () => {
|
|
28
|
+
assert.equal(extractFilePath({ command: 'ls' }, cwd), null);
|
|
29
|
+
assert.equal(extractFilePath({}, cwd), null);
|
|
30
|
+
assert.equal(extractFilePath(null, cwd), null);
|
|
31
|
+
assert.equal(extractFilePath(undefined, cwd), null);
|
|
32
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { install, uninstall } from '../src/install.js';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
// Redirect the config file location by monkey-patching is not trivial in ESM,
|
|
9
|
+
// so test the block logic through the real functions against a temp HOME copy
|
|
10
|
+
// is also invasive. Instead, test idempotency invariants on a scratch string
|
|
11
|
+
// via the exported install/uninstall against a temporary .kimi-code dir.
|
|
12
|
+
|
|
13
|
+
const CONFIG_DIR = join(homedir(), '.kimi-code');
|
|
14
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.toml');
|
|
15
|
+
|
|
16
|
+
function backup() {
|
|
17
|
+
return existsSync(CONFIG_FILE) ? readFileSync(CONFIG_FILE, 'utf8') : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function restore(content) {
|
|
21
|
+
if (content === null) {
|
|
22
|
+
rmSync(CONFIG_FILE, { force: true });
|
|
23
|
+
} else {
|
|
24
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
writeFileSync(CONFIG_FILE, content);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('install then uninstall restores the original config', () => {
|
|
30
|
+
const original = backup();
|
|
31
|
+
try {
|
|
32
|
+
install();
|
|
33
|
+
const withHooks = readFileSync(CONFIG_FILE, 'utf8');
|
|
34
|
+
assert.match(withHooks, /kimi-wakatime --hook/);
|
|
35
|
+
assert.match(withHooks, /event = "PostToolUse"/);
|
|
36
|
+
assert.match(withHooks, /event = "Stop"/);
|
|
37
|
+
assert.match(withHooks, /event = "UserPromptSubmit"/);
|
|
38
|
+
|
|
39
|
+
assert.equal(uninstall(), true);
|
|
40
|
+
const after = readFileSync(CONFIG_FILE, 'utf8');
|
|
41
|
+
assert.equal(after.trimEnd(), (original ?? '').trimEnd());
|
|
42
|
+
assert.equal(uninstall(), false); // nothing left to remove
|
|
43
|
+
} finally {
|
|
44
|
+
restore(original);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('install is idempotent (replaces its own block)', () => {
|
|
49
|
+
const original = backup();
|
|
50
|
+
try {
|
|
51
|
+
install();
|
|
52
|
+
install();
|
|
53
|
+
const content = readFileSync(CONFIG_FILE, 'utf8');
|
|
54
|
+
const matches = content.match(/kimi-wakatime --hook/g) ?? [];
|
|
55
|
+
assert.equal(matches.length, 3); // exactly one block with three hooks
|
|
56
|
+
} finally {
|
|
57
|
+
restore(original);
|
|
58
|
+
}
|
|
59
|
+
});
|