embertime 0.0.0-stage → 0.1.1
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 +5 -0
- package/LICENSE +21 -0
- package/README.md +64 -2
- package/cli.mjs +199 -0
- package/ember-hook.mjs +816 -0
- package/hooks/hooks.json +17 -0
- package/install.mjs +276 -0
- package/package.json +41 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ember contributors
|
|
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
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# embertime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Track your coding time in **VS Code, Cursor, Windsurf, Claude Code, Codex and
|
|
4
|
+
Cursor Agent** with one command. Your time shows up on
|
|
5
|
+
[Ember](https://ember-dev.vercel.app) as a yearly heatmap, language stats and a
|
|
6
|
+
developer leaderboard.
|
|
7
|
+
|
|
8
|
+
Ember records **when** and **in which project** you code. It never sends your
|
|
9
|
+
code, file contents, file paths or AI prompts.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Sign in at [ember-dev.vercel.app](https://ember-dev.vercel.app) with GitHub or
|
|
14
|
+
Google, then open **Settings → Create API key**.
|
|
15
|
+
2. Install the CLI:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g embertime
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
or `bun add -g embertime` / `pnpm add -g embertime`.
|
|
22
|
+
3. Connect everything:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
embertime setup --key ember_YOUR_KEY
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That's it. `setup` finds what's installed on your machine and connects each one:
|
|
29
|
+
|
|
30
|
+
| Tool | What setup does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| VS Code, Cursor, Windsurf, VSCodium | Installs the Ember extension |
|
|
33
|
+
| Claude Code | Adds hooks to `~/.claude/settings.json` (backed up first) |
|
|
34
|
+
| Codex | Adds hooks to `~/.codex/hooks.json`. Then run `/hooks` in Codex once to trust them |
|
|
35
|
+
| Cursor Agent | Adds hooks to `~/.cursor/hooks.json` |
|
|
36
|
+
|
|
37
|
+
Running it again is safe: it only adds what's missing.
|
|
38
|
+
|
|
39
|
+
Prefer not to install globally? `npx embertime setup --key ember_YOUR_KEY` does the same.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
embertime setup --key ember_... # connect every editor and agent found
|
|
45
|
+
embertime status # what's connected, today's time, queued activity
|
|
46
|
+
embertime uninstall # remove hooks and the extension
|
|
47
|
+
embertime uninstall --purge # ...and delete local data in ~/.ember
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Options for `setup`: `--api-url <url>` for a self-hosted Ember server,
|
|
51
|
+
`--vsix <file>` to install the extension from a local file, `--skip-editors` to
|
|
52
|
+
only set up the AI agents.
|
|
53
|
+
|
|
54
|
+
## Reliability and privacy
|
|
55
|
+
|
|
56
|
+
- Activity is queued in `~/.ember/queue.jsonl` while you're offline and sent
|
|
57
|
+
when you're back. Nothing is lost.
|
|
58
|
+
- Hooks always exit immediately and print nothing to Claude Code or Codex, so
|
|
59
|
+
they can never slow down or confuse your agent.
|
|
60
|
+
- Your key is stored in `~/.ember/config.json`, readable only by you (mode 0600).
|
|
61
|
+
- What is sent: timestamp, project name, language, git branch, editor or agent,
|
|
62
|
+
whether a file was saved, and the detected tech stack. A one-way hash of the
|
|
63
|
+
file path is used only to avoid double counting.
|
|
64
|
+
|
|
65
|
+
Requires Node.js 18 or newer. MIT licensed.
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// embertime: one command to connect every editor and AI agent on this machine
|
|
3
|
+
// (VS Code, Cursor, Windsurf, VSCodium, Claude Code, Codex, Cursor Agent).
|
|
4
|
+
//
|
|
5
|
+
// npx embertime setup --key ember_... install everything that is found
|
|
6
|
+
// npx embertime status show what is connected
|
|
7
|
+
// npx embertime uninstall [--purge] remove hooks (and ~/.ember with --purge)
|
|
8
|
+
//
|
|
9
|
+
// Zero dependencies. Respects HOME / EMBER_HOME / CODEX_HOME so it can be tested.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { apply, writeEmberConfig } from './install.mjs';
|
|
17
|
+
|
|
18
|
+
// Public server and Marketplace id. Change DEFAULT_API_URL before publishing.
|
|
19
|
+
export const DEFAULT_API_URL = process.env.EMBER_DEFAULT_API_URL || 'https://ember-dev.vercel.app';
|
|
20
|
+
export const EXTENSION_ID = 'ember-dev.embertime';
|
|
21
|
+
const KEY_RE = /^ember_[0-9a-f]{40}$/i;
|
|
22
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const PAYLOAD = ['ember-hook.mjs', 'install.mjs', 'package.json'];
|
|
24
|
+
const EDITORS = [
|
|
25
|
+
{ cli: 'code', name: 'VS Code' },
|
|
26
|
+
{ cli: 'cursor', name: 'Cursor' },
|
|
27
|
+
{ cli: 'windsurf', name: 'Windsurf' },
|
|
28
|
+
{ cli: 'codium', name: 'VSCodium' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const env = process.env;
|
|
32
|
+
const home = () => env.HOME || os.homedir();
|
|
33
|
+
const emberHome = () => env.EMBER_HOME || path.join(home(), '.ember');
|
|
34
|
+
|
|
35
|
+
const c = process.stdout.isTTY && !env.NO_COLOR
|
|
36
|
+
? { ok: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, warn: (s) => `\x1b[33m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` }
|
|
37
|
+
: { ok: (s) => s, dim: (s) => s, warn: (s) => s, bold: (s) => s };
|
|
38
|
+
|
|
39
|
+
function onPath(cmd) {
|
|
40
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8' });
|
|
41
|
+
return r.status === 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Copy the hook to a stable place. npx caches get cleaned, so hooks must not point into them. */
|
|
45
|
+
function installPayload() {
|
|
46
|
+
const dest = path.join(emberHome(), 'agent-hook');
|
|
47
|
+
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
48
|
+
for (const f of PAYLOAD) fs.copyFileSync(path.join(HERE, f), path.join(dest, f));
|
|
49
|
+
return path.join(dest, 'ember-hook.mjs');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readConfig() {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(path.join(emberHome(), 'config.json'), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parse(argv) {
|
|
61
|
+
const o = { _: [] };
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
if (a === '--key') o.key = argv[++i];
|
|
65
|
+
else if (a.startsWith('--key=')) o.key = a.slice(6);
|
|
66
|
+
else if (a === '--api-url') o.apiUrl = argv[++i];
|
|
67
|
+
else if (a.startsWith('--api-url=')) o.apiUrl = a.slice(10);
|
|
68
|
+
else if (a === '--vsix') o.vsix = argv[++i];
|
|
69
|
+
else if (a === '--skip-editors') o.skipEditors = true;
|
|
70
|
+
else if (a === '--purge') o.purge = true;
|
|
71
|
+
else if (a === '-h' || a === '--help') o.help = true;
|
|
72
|
+
else o._.push(a);
|
|
73
|
+
}
|
|
74
|
+
return o;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function setup(o) {
|
|
78
|
+
const cfg = readConfig();
|
|
79
|
+
const key = o.key || env.EMBER_API_KEY || cfg.api_key;
|
|
80
|
+
if (!key || !KEY_RE.test(key)) {
|
|
81
|
+
console.error('Missing or invalid API key. Copy it from Settings on your Ember dashboard:\n npx embertime setup --key ember_...');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const apiUrl = (o.apiUrl || cfg.api_url || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
85
|
+
|
|
86
|
+
// Check the key against the server before touching any config.
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${apiUrl}/api/v1/me/today`, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(6000) });
|
|
89
|
+
if (res.status === 401) {
|
|
90
|
+
console.error(`The server at ${apiUrl} rejected that key. Create a new one in Settings and try again.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
const me = await res.json();
|
|
95
|
+
console.log(`${c.ok('✓')} Signed in as ${c.bold('@' + me.username)} on ${apiUrl}`);
|
|
96
|
+
} else console.log(c.warn(`! ${apiUrl} answered ${res.status}; continuing, activity will queue until it is reachable.`));
|
|
97
|
+
} catch {
|
|
98
|
+
console.log(c.warn(`! Could not reach ${apiUrl}; continuing, activity will queue until it is reachable.`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
writeEmberConfig({ key, apiUrl, env });
|
|
102
|
+
console.log(`${c.ok('✓')} Saved key to ${path.join(emberHome(), 'config.json')} ${c.dim('(private, 0600)')}`);
|
|
103
|
+
|
|
104
|
+
const script = installPayload();
|
|
105
|
+
const results = [];
|
|
106
|
+
|
|
107
|
+
const hasClaude = fs.existsSync(path.join(home(), '.claude')) || onPath('claude');
|
|
108
|
+
const hasCodex = fs.existsSync(env.CODEX_HOME || path.join(home(), '.codex')) || onPath('codex');
|
|
109
|
+
const hasCursor = fs.existsSync(path.join(home(), '.cursor')) || onPath('cursor');
|
|
110
|
+
for (const [target, found, label] of [
|
|
111
|
+
['claude', hasClaude, 'Claude Code'],
|
|
112
|
+
['codex', hasCodex, 'Codex'],
|
|
113
|
+
['cursor', hasCursor, 'Cursor Agent'],
|
|
114
|
+
]) {
|
|
115
|
+
if (!found) {
|
|
116
|
+
results.push([label, 'not found, skipped']);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const r = apply(target, { env, script });
|
|
120
|
+
results.push([label, r.changed ? `hooks installed${r.backup ? c.dim(` (backup: ${r.backup})`) : ''}` : 'already up to date', true]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!o.skipEditors) {
|
|
124
|
+
for (const e of EDITORS) {
|
|
125
|
+
if (!onPath(e.cli)) continue;
|
|
126
|
+
const r = spawnSync(e.cli, ['--install-extension', o.vsix || EXTENSION_ID, '--force'], { encoding: 'utf8' });
|
|
127
|
+
results.push([e.name, r.status === 0 ? 'extension installed' : `install failed: ${(r.stderr || r.stdout).trim().split('\n').pop()}`, r.status === 0]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log('');
|
|
132
|
+
for (const [label, msg, ok] of results) console.log(`${ok ? c.ok('✓') : c.dim('–')} ${label.padEnd(12)} ${msg}`);
|
|
133
|
+
if (hasCodex) console.log(`\n${c.warn('Codex:')} open Codex and run /hooks once to trust the Ember entries.`);
|
|
134
|
+
console.log(`\nDone. Write some code and watch ${apiUrl}/dashboard light up.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function status() {
|
|
138
|
+
const cfg = readConfig();
|
|
139
|
+
console.log(`config ${cfg.api_key ? `key …${cfg.api_key.slice(-4)}, server ${cfg.api_url}` : 'no key (run setup)'}`);
|
|
140
|
+
for (const [label, file] of [
|
|
141
|
+
['claude', path.join(home(), '.claude', 'settings.json')],
|
|
142
|
+
['codex', path.join(env.CODEX_HOME || path.join(home(), '.codex'), 'hooks.json')],
|
|
143
|
+
['cursor', path.join(home(), '.cursor', 'hooks.json')],
|
|
144
|
+
]) {
|
|
145
|
+
let on = false;
|
|
146
|
+
try { on = fs.readFileSync(file, 'utf8').includes('ember-hook.mjs'); } catch { /* missing */ }
|
|
147
|
+
console.log(`${label.padEnd(8)} ${on ? 'hooks installed' : 'not connected'}`);
|
|
148
|
+
}
|
|
149
|
+
const q = path.join(emberHome(), 'queue.jsonl');
|
|
150
|
+
const queued = fs.existsSync(q) ? fs.readFileSync(q, 'utf8').split('\n').filter(Boolean).length : 0;
|
|
151
|
+
console.log(`queue ${queued} heartbeat(s) waiting`);
|
|
152
|
+
const err = path.join(emberHome(), 'last_error');
|
|
153
|
+
if (fs.existsSync(err)) console.log(`error ${fs.readFileSync(err, 'utf8').trim()}`);
|
|
154
|
+
if (cfg.api_key) {
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(`${cfg.api_url}/api/v1/me/today`, { headers: { Authorization: `Bearer ${cfg.api_key}` }, signal: AbortSignal.timeout(6000) });
|
|
157
|
+
const body = res.ok ? await res.json() : null;
|
|
158
|
+
console.log(`server ${body ? `today ${body.text} as @${body.username}` : `HTTP ${res.status}`}`);
|
|
159
|
+
} catch {
|
|
160
|
+
console.log('server unreachable');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function uninstall(o) {
|
|
166
|
+
for (const t of ['claude', 'codex', 'cursor']) {
|
|
167
|
+
try {
|
|
168
|
+
const r = apply(t, { uninstall: true, env });
|
|
169
|
+
console.log(`${t.padEnd(8)} ${r.changed ? 'hooks removed' : 'nothing to remove'}`);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.log(`${t.padEnd(8)} skipped: ${e.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const e of EDITORS) if (onPath(e.cli)) spawnSync(e.cli, ['--uninstall-extension', EXTENSION_ID], { stdio: 'ignore' });
|
|
175
|
+
if (o.purge) {
|
|
176
|
+
fs.rmSync(emberHome(), { recursive: true, force: true });
|
|
177
|
+
console.log(`removed ${emberHome()}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const HELP = `embertime: track coding time across editors and AI agents
|
|
182
|
+
|
|
183
|
+
npx embertime setup --key <ember_key> [--api-url <url>] [--vsix <file>] [--skip-editors]
|
|
184
|
+
npx embertime status
|
|
185
|
+
npx embertime uninstall [--purge]
|
|
186
|
+
|
|
187
|
+
setup finds VS Code, Cursor, Windsurf, VSCodium, Claude Code, Codex and Cursor's
|
|
188
|
+
agent on this machine and connects each one. Re-running it is safe.`;
|
|
189
|
+
|
|
190
|
+
const o = parse(process.argv.slice(2));
|
|
191
|
+
const cmd = o._[0];
|
|
192
|
+
if (o.help || !cmd) console.log(HELP);
|
|
193
|
+
else if (cmd === 'setup') await setup(o);
|
|
194
|
+
else if (cmd === 'status') await status();
|
|
195
|
+
else if (cmd === 'uninstall') uninstall(o);
|
|
196
|
+
else {
|
|
197
|
+
console.error(`Unknown command "${cmd}".\n\n${HELP}`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|