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 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
+ }
@@ -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
+ }
@@ -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
+ });