embertime 0.1.2 → 0.1.4
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/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -1
- package/cli.mjs +2 -1
- package/ember-hook.mjs +66 -7
- package/package.json +4 -2
- package/postinstall.mjs +20 -0
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ Cursor Agent** with one command. Your time shows up on
|
|
|
5
5
|
[Ember](https://ember-dev.vercel.app) as a yearly heatmap, language stats and a
|
|
6
6
|
developer leaderboard.
|
|
7
7
|
|
|
8
|
-
Ember records **when** and **in which project** you code.
|
|
8
|
+
Ember records **when** and **in which project** you write code. Only writing counts:
|
|
9
|
+
an editor or agent that is just open adds no time. It never sends your
|
|
9
10
|
code, file contents, file paths or AI prompts.
|
|
10
11
|
|
|
11
12
|
## Setup
|
package/cli.mjs
CHANGED
|
@@ -128,7 +128,7 @@ async function setup(o) {
|
|
|
128
128
|
results.push([e.name, r.status === 0 ? 'extension installed (restart the editor)' : `install failed: ${why}`, r.status === 0]);
|
|
129
129
|
}
|
|
130
130
|
if (editors.length === 0) {
|
|
131
|
-
results.push(['Editors', 'none found. In VS Code, Cursor or Windsurf, open Extensions and install
|
|
131
|
+
results.push(['Editors', 'none found. In VS Code, Cursor or Windsurf, open Extensions, search "embertime" and install it']);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -193,6 +193,7 @@ function uninstall(o) {
|
|
|
193
193
|
const HELP = `embertime: track coding time across editors and AI agents
|
|
194
194
|
|
|
195
195
|
npx embertime setup --key <ember_key> [--api-url <url>] [--vsix <file>] [--skip-editors]
|
|
196
|
+
npx embertime setup re-run with the key you already saved
|
|
196
197
|
npx embertime status
|
|
197
198
|
npx embertime uninstall [--purge]
|
|
198
199
|
|
package/ember-hook.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// * never writes to stdout for Claude Code / Codex (they may interpret it); for
|
|
10
10
|
// Cursor it prints the minimal JSON reply Cursor expects. Always exits 0
|
|
11
11
|
// * never sends file contents, raw paths or prompt text: files are reduced
|
|
12
|
-
// to
|
|
12
|
+
// to an HMAC of the absolute path, keyed with a random per-machine secret
|
|
13
|
+
// (~/.ember/entity.key), and the project to the git root's basename
|
|
13
14
|
// * heartbeats are appended to ~/.ember/queue.jsonl before any network I/O,
|
|
14
15
|
// so nothing is lost when the server is down
|
|
15
16
|
//
|
|
@@ -21,7 +22,7 @@ import path from 'node:path';
|
|
|
21
22
|
import crypto from 'node:crypto';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
23
24
|
|
|
24
|
-
export const VERSION = '0.1.
|
|
25
|
+
export const VERSION = '0.1.4';
|
|
25
26
|
export const AGENTS = ['claude-code', 'codex', 'cursor-agent'];
|
|
26
27
|
|
|
27
28
|
/** Events whose heartbeats are throttled: they fire on every tool call or file edit. */
|
|
@@ -59,6 +60,7 @@ function paths(home) {
|
|
|
59
60
|
state: path.join(home, 'hook-state.json'),
|
|
60
61
|
log: path.join(home, 'hook.log'),
|
|
61
62
|
lastError: path.join(home, 'last_error'),
|
|
63
|
+
entityKey: path.join(home, 'entity.key'),
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -409,8 +411,57 @@ export function extractFilePath(toolInput, cwd) {
|
|
|
409
411
|
return path.resolve(cwd || process.cwd(), p);
|
|
410
412
|
}
|
|
411
413
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
+
/**
|
|
415
|
+
* File identity sent as `entity`: HMAC-SHA256 of the absolute path under the
|
|
416
|
+
* per-machine key, cut to the 40 hex characters the server accepts. A plain hash
|
|
417
|
+
* of a path could be reversed by guessing likely paths; without the key it can't.
|
|
418
|
+
*/
|
|
419
|
+
export function entityHash(key, file) {
|
|
420
|
+
return crypto.createHmac('sha256', key).update(file).digest('hex').slice(0, 40);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const ENTITY_KEY_RE = /^[a-f0-9]{64}$/;
|
|
424
|
+
|
|
425
|
+
function readEntityKey(file) {
|
|
426
|
+
try {
|
|
427
|
+
const key = fs.readFileSync(file, 'utf8').trim();
|
|
428
|
+
return ENTITY_KEY_RE.test(key) ? key : undefined;
|
|
429
|
+
} catch {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The random key shared by every collector on this machine (mode 0600), created
|
|
436
|
+
* on first use. Concurrent first runs agree on one key: the first hard link wins.
|
|
437
|
+
* If it can't be stored, a one-off key is used: entities then differ between runs,
|
|
438
|
+
* which only weakens de-duplication, never privacy.
|
|
439
|
+
*/
|
|
440
|
+
export function loadEntityKey(home) {
|
|
441
|
+
const file = paths(home).entityKey;
|
|
442
|
+
const existing = readEntityKey(file);
|
|
443
|
+
if (existing) return existing;
|
|
444
|
+
const key = crypto.randomBytes(32).toString('hex');
|
|
445
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
446
|
+
try {
|
|
447
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
448
|
+
fs.writeFileSync(tmp, `${key}\n`, { mode: 0o600 });
|
|
449
|
+
try {
|
|
450
|
+
fs.linkSync(tmp, file);
|
|
451
|
+
return key;
|
|
452
|
+
} catch (e) {
|
|
453
|
+
if (e.code !== 'EEXIST') throw e;
|
|
454
|
+
const winner = readEntityKey(file);
|
|
455
|
+
if (winner) return winner;
|
|
456
|
+
fs.renameSync(tmp, file); // existing file is corrupt: replace it
|
|
457
|
+
return key;
|
|
458
|
+
}
|
|
459
|
+
} catch (e) {
|
|
460
|
+
log(home, 'warn', `could not store entity key: ${e.message}`);
|
|
461
|
+
return key;
|
|
462
|
+
} finally {
|
|
463
|
+
fs.rmSync(tmp, { force: true });
|
|
464
|
+
}
|
|
414
465
|
}
|
|
415
466
|
|
|
416
467
|
export function parsePayload(text) {
|
|
@@ -427,7 +478,7 @@ export function parsePayload(text) {
|
|
|
427
478
|
* Build exactly one heartbeat from a hook payload. Only derived, non-sensitive
|
|
428
479
|
* fields are copied: never prompt text, tool output, file contents or paths.
|
|
429
480
|
*/
|
|
430
|
-
export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env } = {}) {
|
|
481
|
+
export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env = process.env, entityKey } = {}) {
|
|
431
482
|
// Claude Code and Codex send `cwd`; Cursor sends `workspace_roots` (and sets CURSOR_PROJECT_DIR).
|
|
432
483
|
const roots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots.filter((r) => typeof r === 'string' && r) : [];
|
|
433
484
|
const cwd =
|
|
@@ -450,7 +501,7 @@ export function buildHeartbeat(payload, { agent, now = Date.now() / 1000, env =
|
|
|
450
501
|
extractFilePath(payload.tool_input, cwd) ||
|
|
451
502
|
(typeof payload.file_path === 'string' && payload.file_path.trim() ? path.resolve(cwd, payload.file_path.trim()) : undefined);
|
|
452
503
|
if (file) {
|
|
453
|
-
hb.entity =
|
|
504
|
+
hb.entity = entityHash(entityKey ?? loadEntityKey(emberHome(env)), file);
|
|
454
505
|
const language = languageForPath(file);
|
|
455
506
|
if (language) hb.language = language;
|
|
456
507
|
}
|
|
@@ -759,6 +810,15 @@ export async function runHook({ argv = [], env = process.env, stdinText = '' } =
|
|
|
759
810
|
const payload = parsePayload(stdinText);
|
|
760
811
|
const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
761
812
|
const hb = buildHeartbeat(payload, { agent, env });
|
|
813
|
+
const { apiUrl, apiKey } = loadConfig(home, env);
|
|
814
|
+
|
|
815
|
+
// Only an agent changing a file counts as coding. Opening a session,
|
|
816
|
+
// sending a prompt or reading files does not; those events just deliver
|
|
817
|
+
// anything still queued.
|
|
818
|
+
if (!hb.is_write) {
|
|
819
|
+
await flushQueue(home, { apiUrl, apiKey });
|
|
820
|
+
return { status: 'not-writing' };
|
|
821
|
+
}
|
|
762
822
|
|
|
763
823
|
const state = readState(home);
|
|
764
824
|
if (shouldThrottle(state, hb, event)) return { status: 'throttled' };
|
|
@@ -766,7 +826,6 @@ export async function runHook({ argv = [], env = process.env, stdinText = '' } =
|
|
|
766
826
|
writeState(home, state);
|
|
767
827
|
|
|
768
828
|
appendToQueue(home, hb);
|
|
769
|
-
const { apiUrl, apiKey } = loadConfig(home, env);
|
|
770
829
|
return await flushQueue(home, { apiUrl, apiKey });
|
|
771
830
|
} catch (e) {
|
|
772
831
|
log(home, 'error', `hook failed: ${e?.stack || e}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "embertime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Connect VS Code, Cursor, Windsurf, Claude Code and Codex to Ember in one command. Tracks coding time, never code or prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"cli.mjs",
|
|
11
11
|
"editors.mjs",
|
|
12
|
+
"postinstall.mjs",
|
|
12
13
|
"ember-hook.mjs",
|
|
13
14
|
"install.mjs",
|
|
14
15
|
"hooks",
|
|
@@ -30,7 +31,8 @@
|
|
|
30
31
|
"heatmap"
|
|
31
32
|
],
|
|
32
33
|
"scripts": {
|
|
33
|
-
"test": "node --test test/*.test.mjs"
|
|
34
|
+
"test": "node --test test/*.test.mjs",
|
|
35
|
+
"postinstall": "node postinstall.mjs"
|
|
34
36
|
},
|
|
35
37
|
"repository": {
|
|
36
38
|
"type": "git",
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Runs after `npm install -g embertime` (and upgrades). If Ember was already set
|
|
2
|
+
// up on this machine, refresh the hook copy in ~/.ember/agent-hook so agents run
|
|
3
|
+
// the new version straight away. Your key in ~/.ember/config.json is untouched.
|
|
4
|
+
// Best effort: it never fails the install and does nothing on a fresh machine.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const dest = path.join(process.env.EMBER_HOME || path.join(os.homedir(), '.ember'), 'agent-hook');
|
|
14
|
+
if (fs.existsSync(path.join(dest, 'ember-hook.mjs'))) {
|
|
15
|
+
for (const f of ['ember-hook.mjs', 'install.mjs', 'package.json']) fs.copyFileSync(path.join(here, f), path.join(dest, f));
|
|
16
|
+
console.log('embertime: updated the hook in ~/.ember/agent-hook (your key is unchanged).');
|
|
17
|
+
}
|
|
18
|
+
} catch {
|
|
19
|
+
/* never block an install */
|
|
20
|
+
}
|