claude-code-wakatime 1.0.0 → 2.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.
@@ -0,0 +1,395 @@
1
+ import adm_zip from 'adm-zip';
2
+ import * as child_process from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+ import * as request from 'request';
7
+ import * as semver from 'semver';
8
+ import * as which from 'which';
9
+
10
+ import { Options, Setting } from './options';
11
+ import { Utils } from './utils';
12
+ import { Logger } from './logger';
13
+
14
+ enum osName {
15
+ darwin = 'darwin',
16
+ windows = 'windows',
17
+ linux = 'linux',
18
+ }
19
+
20
+ export class Dependencies {
21
+ private options: Options;
22
+ private logger: Logger;
23
+ private resourcesLocation: string;
24
+ private cliLocation?: string = undefined;
25
+ private cliLocationGlobal?: string = undefined;
26
+ private cliInstalled: boolean = false;
27
+ private githubDownloadUrl = 'https://github.com/wakatime/wakatime-cli/releases/latest/download';
28
+ private githubReleasesUrl = 'https://api.github.com/repos/wakatime/wakatime-cli/releases/latest';
29
+ private legacyOperatingSystems: {
30
+ [key in osName]?: {
31
+ kernelLessThan: string;
32
+ tag: string;
33
+ }[];
34
+ } = {
35
+ [osName.darwin]: [{ kernelLessThan: '17.0.0', tag: 'v1.39.1-alpha.1' }],
36
+ };
37
+
38
+ constructor(options: Options, logger: Logger) {
39
+ this.options = options;
40
+ this.logger = logger;
41
+ this.resourcesLocation = options.resourcesLocation;
42
+ }
43
+
44
+ public getCliLocation(): string {
45
+ if (this.cliLocation) return this.cliLocation;
46
+
47
+ this.cliLocation = this.getCliLocationGlobal();
48
+ if (this.cliLocation) return this.cliLocation;
49
+
50
+ const osname = this.osName();
51
+ const arch = this.architecture();
52
+ const ext = Utils.isWindows() ? '.exe' : '';
53
+ const binary = `wakatime-cli-${osname}-${arch}${ext}`;
54
+ this.cliLocation = path.join(this.resourcesLocation, binary);
55
+
56
+ return this.cliLocation;
57
+ }
58
+
59
+ public getCliLocationGlobal(): string | undefined {
60
+ if (this.cliLocationGlobal) return this.cliLocationGlobal;
61
+
62
+ const binaryName = `wakatime-cli${Utils.isWindows() ? '.exe' : ''}`;
63
+ const path = which.sync(binaryName, { nothrow: true });
64
+ if (path) {
65
+ this.cliLocationGlobal = path;
66
+ this.logger.debug(`Using global wakatime-cli location: ${path}`);
67
+ }
68
+
69
+ return this.cliLocationGlobal;
70
+ }
71
+
72
+ public isCliInstalled(): boolean {
73
+ if (this.cliInstalled) return true;
74
+ this.cliInstalled = fs.existsSync(this.getCliLocation());
75
+ return this.cliInstalled;
76
+ }
77
+
78
+ public checkAndInstallCli(callback?: () => void): void {
79
+ if (!this.isCliInstalled()) {
80
+ this.installCli(callback ?? (() => {}));
81
+ } else {
82
+ this.isCliLatest((isLatest) => {
83
+ if (!isLatest) {
84
+ this.installCli(callback ?? (() => {}));
85
+ } else {
86
+ callback?.();
87
+ }
88
+ });
89
+ }
90
+ }
91
+
92
+ private isCliLatest(callback: (arg0: boolean) => void): void {
93
+ if (this.getCliLocationGlobal()) {
94
+ callback(true);
95
+ return;
96
+ }
97
+
98
+ let args = ['--version'];
99
+ const options = Utils.buildOptions();
100
+ try {
101
+ child_process.execFile(this.getCliLocation(), args, options, (error, _stdout, stderr) => {
102
+ if (!(error != null)) {
103
+ let currentVersion = _stdout.toString().trim() + stderr.toString().trim();
104
+ this.logger.debug(`Current wakatime-cli version is ${currentVersion}`);
105
+
106
+ if (currentVersion === '<local-build>') {
107
+ callback(true);
108
+ return;
109
+ }
110
+
111
+ const tag = this.legacyReleaseTag();
112
+ if (tag && currentVersion !== tag) {
113
+ callback(false);
114
+ return;
115
+ }
116
+
117
+ const accessed = this.options.getSetting('internal', 'cli_version_last_accessed', true);
118
+ const now = Math.round(Date.now() / 1000);
119
+ const lastAccessed = parseInt(accessed ?? '0');
120
+ const fourHours = 4 * 3600;
121
+ if (lastAccessed && lastAccessed + fourHours > now) {
122
+ this.logger.debug(`Skip checking for wakatime-cli updates because recently checked ${now - lastAccessed} seconds ago.`);
123
+ callback(true);
124
+ return;
125
+ }
126
+
127
+ this.logger.debug('Checking for updates to wakatime-cli...');
128
+ this.getLatestCliVersion((latestVersion) => {
129
+ if (currentVersion === latestVersion) {
130
+ this.logger.debug('wakatime-cli is up to date');
131
+ callback(true);
132
+ } else if (latestVersion) {
133
+ this.logger.debug(`Found an updated wakatime-cli ${latestVersion}`);
134
+ callback(false);
135
+ } else {
136
+ this.logger.debug('Unable to find latest wakatime-cli version');
137
+ callback(false);
138
+ }
139
+ });
140
+ } else {
141
+ callback(false);
142
+ }
143
+ });
144
+ } catch (e) {
145
+ callback(false);
146
+ }
147
+ }
148
+
149
+ private getLatestCliVersion(callback: (arg0: string) => void): void {
150
+ const proxy = this.options.getSetting('settings', 'proxy');
151
+ const noSSLVerify = this.options.getSetting('settings', 'no_ssl_verify');
152
+ let options: request.CoreOptions & { url: string } = {
153
+ url: this.githubReleasesUrl,
154
+ json: true,
155
+ headers: {
156
+ 'User-Agent': 'github.com/wakatime/vscode-wakatime',
157
+ },
158
+ };
159
+ this.logger.debug(`Fetching latest wakatime-cli version from GitHub API: ${options.url}`);
160
+ if (proxy) {
161
+ this.logger.debug(`Using Proxy: ${proxy}`);
162
+ options['proxy'] = proxy;
163
+ }
164
+ if (noSSLVerify === 'true') options['strictSSL'] = false;
165
+ try {
166
+ request.get(options, (error, response, json) => {
167
+ if (!error && response && response.statusCode == 200) {
168
+ this.logger.debug(`GitHub API Response ${response.statusCode}`);
169
+ const latestCliVersion = json['tag_name'];
170
+ this.logger.debug(`Latest wakatime-cli version from GitHub: ${latestCliVersion}`);
171
+ this.options.setSetting('internal', 'cli_version_last_accessed', String(Math.round(Date.now() / 1000)), true);
172
+ callback(latestCliVersion);
173
+ } else {
174
+ if (response) {
175
+ this.logger.warn(`GitHub API Response ${response.statusCode}: ${error}`);
176
+ } else {
177
+ this.logger.warn(`GitHub API Response Error: ${error}`);
178
+ }
179
+ callback('');
180
+ }
181
+ });
182
+ } catch (e) {
183
+ this.logger.warnException(e);
184
+ callback('');
185
+ }
186
+ }
187
+
188
+ private installCli(callback: () => void): void {
189
+ this.logger.debug(`Downloading wakatime-cli from GitHub...`);
190
+ const url = this.cliDownloadUrl();
191
+ let zipFile = path.join(this.resourcesLocation, 'wakatime-cli' + this.randStr() + '.zip');
192
+ this.downloadFile(
193
+ url,
194
+ zipFile,
195
+ () => {
196
+ this.extractCli(zipFile, callback);
197
+ },
198
+ callback,
199
+ );
200
+ }
201
+
202
+ private isSymlink(file: string): boolean {
203
+ try {
204
+ return fs.lstatSync(file).isSymbolicLink();
205
+ } catch (_) {}
206
+ return false;
207
+ }
208
+
209
+ private extractCli(zipFile: string, callback: () => void): void {
210
+ this.logger.debug(`Extracting wakatime-cli into "${this.resourcesLocation}"...`);
211
+ this.backupCli();
212
+ this.unzip(zipFile, this.resourcesLocation, (unzipped) => {
213
+ if (!unzipped) {
214
+ this.restoreCli();
215
+ } else if (!Utils.isWindows()) {
216
+ this.removeCli();
217
+ const cli = this.getCliLocation();
218
+ try {
219
+ this.logger.debug('Chmod 755 wakatime-cli...');
220
+ fs.chmodSync(cli, 0o755);
221
+ } catch (e) {
222
+ this.logger.warnException(e);
223
+ }
224
+ const ext = Utils.isWindows() ? '.exe' : '';
225
+ const link = path.join(this.resourcesLocation, `wakatime-cli${ext}`);
226
+ if (!this.isSymlink(link)) {
227
+ try {
228
+ this.logger.debug(`Create symlink from wakatime-cli to ${cli}`);
229
+ fs.symlinkSync(cli, link);
230
+ } catch (e) {
231
+ this.logger.warnException(e);
232
+ try {
233
+ fs.copyFileSync(cli, link);
234
+ fs.chmodSync(link, 0o755);
235
+ } catch (e2) {
236
+ this.logger.warnException(e2);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ callback();
242
+ });
243
+ this.logger.debug('Finished extracting wakatime-cli.');
244
+ }
245
+
246
+ private backupCli() {
247
+ if (fs.existsSync(this.getCliLocation())) {
248
+ fs.renameSync(this.getCliLocation(), `${this.getCliLocation()}.backup`);
249
+ }
250
+ }
251
+
252
+ private restoreCli() {
253
+ const backup = `${this.getCliLocation()}.backup`;
254
+ if (fs.existsSync(backup)) {
255
+ fs.renameSync(backup, this.getCliLocation());
256
+ }
257
+ }
258
+
259
+ private removeCli() {
260
+ const backup = `${this.getCliLocation()}.backup`;
261
+ if (fs.existsSync(backup)) {
262
+ fs.unlinkSync(backup);
263
+ }
264
+ }
265
+
266
+ private downloadFile(url: string, outputFile: string, callback: () => void, error: () => void): void {
267
+ const proxy = this.options.getSetting('settings', 'proxy');
268
+ const noSSLVerify = this.options.getSetting('settings', 'no_ssl_verify');
269
+ let options: request.CoreOptions & { url: string } = { url: url };
270
+ if (proxy) {
271
+ this.logger.debug(`Using Proxy: ${proxy}`);
272
+ options['proxy'] = proxy;
273
+ }
274
+ if (noSSLVerify === 'true') options['strictSSL'] = false;
275
+ try {
276
+ let r = request.get(options);
277
+ r.on('error', (e) => {
278
+ this.logger.warn(`Failed to download ${url}`);
279
+ this.logger.warn(e.toString());
280
+ error();
281
+ });
282
+ let out = fs.createWriteStream(outputFile);
283
+ r.pipe(out);
284
+ r.on('end', () => {
285
+ out.on('finish', () => {
286
+ callback();
287
+ });
288
+ });
289
+ } catch (e) {
290
+ this.logger.warnException(e);
291
+ callback();
292
+ }
293
+ }
294
+
295
+ private unzip(file: string, outputDir: string, callback: (unzipped: boolean) => void): void {
296
+ if (fs.existsSync(file)) {
297
+ try {
298
+ let zip = new adm_zip(file);
299
+ zip.extractAllTo(outputDir, true);
300
+ fs.unlinkSync(file);
301
+ callback(true);
302
+ return;
303
+ } catch (e) {
304
+ this.logger.warnException(e);
305
+ }
306
+ try {
307
+ fs.unlinkSync(file);
308
+ } catch (e2) {
309
+ this.logger.warnException(e2);
310
+ }
311
+ callback(false);
312
+ }
313
+ }
314
+
315
+ private legacyReleaseTag() {
316
+ const osname = this.osName() as osName;
317
+ const legacyOS = this.legacyOperatingSystems[osname];
318
+ if (!legacyOS) return;
319
+ const version = legacyOS.find((spec) => {
320
+ try {
321
+ return semver.lt(os.release(), spec.kernelLessThan);
322
+ } catch (e) {
323
+ return false;
324
+ }
325
+ });
326
+ return version?.tag;
327
+ }
328
+
329
+ private architecture(): string {
330
+ const arch = os.arch();
331
+ if (arch.indexOf('32') > -1) return '386';
332
+ if (arch.indexOf('x64') > -1) return 'amd64';
333
+ return arch;
334
+ }
335
+
336
+ private osName(): string {
337
+ let osname = os.platform() as string;
338
+ if (osname == 'win32') osname = 'windows';
339
+ return osname;
340
+ }
341
+
342
+ private cliDownloadUrl(): string {
343
+ const osname = this.osName();
344
+ const arch = this.architecture();
345
+
346
+ // Use legacy wakatime-cli release to support older operating systems
347
+ const tag = this.legacyReleaseTag();
348
+ if (tag) {
349
+ return `https://github.com/wakatime/wakatime-cli/releases/download/${tag}/wakatime-cli-${osname}-${arch}.zip`;
350
+ }
351
+
352
+ const validCombinations = [
353
+ 'android-amd64',
354
+ 'android-arm64',
355
+ 'darwin-amd64',
356
+ 'darwin-arm64',
357
+ 'freebsd-386',
358
+ 'freebsd-amd64',
359
+ 'freebsd-arm',
360
+ 'linux-386',
361
+ 'linux-amd64',
362
+ 'linux-arm',
363
+ 'linux-arm64',
364
+ 'netbsd-386',
365
+ 'netbsd-amd64',
366
+ 'netbsd-arm',
367
+ 'openbsd-386',
368
+ 'openbsd-amd64',
369
+ 'openbsd-arm',
370
+ 'openbsd-arm64',
371
+ 'windows-386',
372
+ 'windows-amd64',
373
+ 'windows-arm64',
374
+ ];
375
+ if (!validCombinations.includes(`${osname}-${arch}`)) this.reportMissingPlatformSupport(osname, arch);
376
+
377
+ return `${this.githubDownloadUrl}/wakatime-cli-${osname}-${arch}.zip`;
378
+ }
379
+
380
+ private reportMissingPlatformSupport(osname: string, architecture: string): void {
381
+ const url = `https://api.wakatime.com/api/v1/cli-missing?osname=${osname}&architecture=${architecture}&plugin=vscode`;
382
+ const proxy = this.options.getSetting('settings', 'proxy');
383
+ const noSSLVerify = this.options.getSetting('settings', 'no_ssl_verify');
384
+ let options: request.CoreOptions & { url: string } = { url: url };
385
+ if (proxy) options['proxy'] = proxy;
386
+ if (noSSLVerify === 'true') options['strictSSL'] = false;
387
+ try {
388
+ request.get(options);
389
+ } catch (e) {}
390
+ }
391
+
392
+ private randStr(): string {
393
+ return (Math.random() + 1).toString(36).substring(7);
394
+ }
395
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,11 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import { execFileSync } from 'child_process';
7
+ import { Options } from './options';
8
+ import { VERSION } from './version';
9
+ import { Dependencies } from './dependencies';
10
+ import { Utils } from './utils';
11
+ import { Logger, LogLevel } from './logger';
7
12
 
8
13
  const STATE_FILE = path.join(os.homedir(), '.wakatime', 'claude-code.json');
9
14
  const WAKATIME_CLI = path.join(os.homedir(), '.wakatime', 'wakatime-cli');
@@ -12,33 +17,145 @@ type State = {
12
17
  lastHeartbeatAt?: number;
13
18
  };
14
19
 
15
- function timestamp() {
16
- return Date.now() / 1000;
17
- }
20
+ type Input = {
21
+ session_id: string;
22
+ transcript_path: string;
23
+ cwd: string;
24
+ hook_event_name: string;
25
+ };
18
26
 
19
27
  function shouldSendHeartbeat(): boolean {
20
28
  try {
21
- const last = (JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as State).lastHeartbeatAt ?? timestamp();
22
- return timestamp() - last >= 60;
29
+ const last = (JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as State).lastHeartbeatAt ?? Utils.timestamp();
30
+ return Utils.timestamp() - last >= 60;
23
31
  } catch {
24
32
  return true;
25
33
  }
26
34
  }
27
35
 
36
+ function parseInput() {
37
+ try {
38
+ const stdinData = fs.readFileSync(0, 'utf-8');
39
+ if (stdinData.trim()) {
40
+ const input: Input = JSON.parse(stdinData);
41
+ return input;
42
+ }
43
+ } catch (err) {
44
+ console.error(err);
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function getLastHeartbeat() {
50
+ try {
51
+ const stateData = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as State;
52
+ return stateData.lastHeartbeatAt ?? 0;
53
+ } catch {
54
+ return 0;
55
+ }
56
+ }
57
+
58
+ function calculateLineChanges(transcriptPath: string): number {
59
+ try {
60
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
61
+ return 0;
62
+ }
63
+
64
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
65
+ const lines = content.split('\n');
66
+ let totalLineChanges = 0;
67
+
68
+ const lastHeartbeatAt = getLastHeartbeat();
69
+ for (const line of lines) {
70
+ if (line.trim()) {
71
+ try {
72
+ const logEntry = JSON.parse(line);
73
+
74
+ // Only count changes since last heartbeat
75
+ if (logEntry.timestamp && logEntry.toolUseResult?.structuredPatch) {
76
+ const entryTimestamp = new Date(logEntry.timestamp).getTime() / 1000;
77
+ if (entryTimestamp >= lastHeartbeatAt) {
78
+ const patches = logEntry.toolUseResult.structuredPatch;
79
+ for (const patch of patches) {
80
+ if (patch.newLines !== undefined && patch.oldLines !== undefined) {
81
+ totalLineChanges += patch.newLines - patch.oldLines;
82
+ }
83
+ }
84
+ }
85
+ }
86
+ } catch {
87
+ // ignore
88
+ }
89
+ }
90
+ }
91
+
92
+ return totalLineChanges;
93
+ } catch {
94
+ return 0;
95
+ }
96
+ }
97
+
28
98
  function updateState() {
29
99
  fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
30
- fs.writeFileSync(STATE_FILE, JSON.stringify({ lastHeartbeatAt: timestamp() } as State, null, 2));
100
+ fs.writeFileSync(STATE_FILE, JSON.stringify({ lastHeartbeatAt: Utils.timestamp() } as State, null, 2));
31
101
  }
32
102
 
33
- function sendHeartbeat() {
103
+ function sendHeartbeat(inp: Input | undefined, logger: Logger) {
104
+ const projectFolder = inp?.cwd;
34
105
  try {
35
- execFileSync(WAKATIME_CLI, ['--entity', 'claude code', '--entity-type', 'app', '--category', 'ai coding']);
106
+ const args: string[] = [
107
+ '--entity',
108
+ 'claude code',
109
+ '--entity-type',
110
+ 'app',
111
+ '--category',
112
+ 'ai coding',
113
+ '--plugin',
114
+ `claude-code-wakatime/${VERSION}`,
115
+ ];
116
+ if (projectFolder) {
117
+ args.push('--project-folder');
118
+ args.push(projectFolder);
119
+ }
120
+
121
+ if (inp?.transcript_path) {
122
+ const lineChanges = calculateLineChanges(inp.transcript_path);
123
+ if (lineChanges !== 0) {
124
+ args.push('--ai-line-changes');
125
+ args.push(lineChanges.toString());
126
+ }
127
+ }
128
+
129
+ execFileSync(WAKATIME_CLI, args);
36
130
  } catch (err: any) {
37
- console.error('Failed to send WakaTime heartbeat:', err.message);
131
+ logger.errorException(err);
38
132
  }
39
133
  }
40
134
 
41
- if (shouldSendHeartbeat()) {
42
- sendHeartbeat();
43
- updateState();
135
+ function main() {
136
+ const inp = parseInput();
137
+
138
+ const options = new Options();
139
+ const debug = options.getSetting('settings', 'debug');
140
+ const logger = new Logger(debug === 'true' ? LogLevel.DEBUG : LogLevel.INFO);
141
+ const deps = new Dependencies(options, logger);
142
+
143
+ if (inp) {
144
+ try {
145
+ logger.debug(JSON.stringify(inp, null, 2));
146
+ } catch (err) {
147
+ // ignore
148
+ }
149
+ }
150
+
151
+ if (inp?.hook_event_name === 'SessionStart') {
152
+ deps.checkAndInstallCli();
153
+ }
154
+
155
+ if (shouldSendHeartbeat()) {
156
+ sendHeartbeat(inp, logger);
157
+ updateState();
158
+ }
44
159
  }
160
+
161
+ main();
package/src/logger.ts ADDED
@@ -0,0 +1,77 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { Utils } from './utils';
5
+
6
+ export enum LogLevel {
7
+ DEBUG = 0,
8
+ INFO,
9
+ WARN,
10
+ ERROR,
11
+ }
12
+
13
+ const LOG_FILE = path.join(os.homedir(), '.wakatime', 'claude-code.log');
14
+
15
+ export class Logger {
16
+ private level: LogLevel = LogLevel.INFO;
17
+
18
+ constructor(level?: LogLevel) {
19
+ if (level) this.setLevel(level);
20
+ }
21
+
22
+ public getLevel(): LogLevel {
23
+ return this.level;
24
+ }
25
+
26
+ public setLevel(level: LogLevel): void {
27
+ this.level = level;
28
+ }
29
+
30
+ public log(level: LogLevel, msg: string): void {
31
+ if (level >= this.level) {
32
+ msg = `[${Utils.timestamp()}][${LogLevel[level]}] ${msg}\n`;
33
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
34
+ fs.appendFileSync(LOG_FILE, msg);
35
+ }
36
+ }
37
+
38
+ public debug(msg: string): void {
39
+ this.log(LogLevel.DEBUG, msg);
40
+ }
41
+
42
+ public debugException(msg: unknown): void {
43
+ if ((msg as Error).message !== undefined) {
44
+ this.log(LogLevel.DEBUG, (msg as Error).message);
45
+ } else {
46
+ this.log(LogLevel.DEBUG, (msg as Error).toString());
47
+ }
48
+ }
49
+
50
+ public info(msg: string): void {
51
+ this.log(LogLevel.INFO, msg);
52
+ }
53
+
54
+ public warn(msg: string): void {
55
+ this.log(LogLevel.WARN, msg);
56
+ }
57
+
58
+ public warnException(msg: unknown): void {
59
+ if ((msg as Error).message !== undefined) {
60
+ this.log(LogLevel.WARN, (msg as Error).message);
61
+ } else {
62
+ this.log(LogLevel.WARN, (msg as Error).toString());
63
+ }
64
+ }
65
+
66
+ public error(msg: string): void {
67
+ this.log(LogLevel.ERROR, msg);
68
+ }
69
+
70
+ public errorException(msg: unknown): void {
71
+ if ((msg as Error).message !== undefined) {
72
+ this.log(LogLevel.ERROR, (msg as Error).message);
73
+ } else {
74
+ this.log(LogLevel.ERROR, (msg as Error).toString());
75
+ }
76
+ }
77
+ }