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.
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "Ember activity heartbeats for Claude Code",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/ember-hook.mjs\" --agent claude-code" }] }
6
+ ],
7
+ "UserPromptSubmit": [
8
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/ember-hook.mjs\" --agent claude-code" }] }
9
+ ],
10
+ "PostToolUse": [
11
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/ember-hook.mjs\" --agent claude-code" }] }
12
+ ],
13
+ "Stop": [
14
+ { "hooks": [{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/ember-hook.mjs\" --agent claude-code" }] }
15
+ ]
16
+ }
17
+ }
package/install.mjs ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ // Ember hook installer.
3
+ //
4
+ // node install.mjs claude [--uninstall] [--key <ember_...>] [--api-url <url>]
5
+ // node install.mjs codex [--uninstall] [--key <ember_...>] [--api-url <url>]
6
+ // node install.mjs --key <ember_...> (only write ~/.ember/config.json)
7
+ //
8
+ // claude -> merges hooks into ~/.claude/settings.json
9
+ // codex -> merges hooks into ~/.codex/hooks.json (or $CODEX_HOME/hooks.json)
10
+ //
11
+ // The target file is backed up (<file>.bak-<timestamp>) before any change.
12
+ // Re-running is idempotent: existing Ember entries (any command that
13
+ // references ember-hook.mjs) are replaced, everything else is left untouched.
14
+ // HOME (and EMBER_HOME / CODEX_HOME) are respected, so it can be tested safely.
15
+
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const HOOK_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ember-hook.mjs');
22
+ const MARKER = 'ember-hook.mjs';
23
+ const KEY_RE = /^ember_[0-9a-f]{40}$/i;
24
+
25
+ export const TARGETS = {
26
+ claude: {
27
+ agent: 'claude-code',
28
+ events: ['SessionStart', 'UserPromptSubmit', 'PostToolUse', 'Stop'],
29
+ file: (env) => path.join(homeDir(env), '.claude', 'settings.json'),
30
+ },
31
+ cursor: {
32
+ // Cursor's agent hooks: flat lists per event in ~/.cursor/hooks.json, plus "version": 1.
33
+ agent: 'cursor-agent',
34
+ format: 'cursor',
35
+ events: ['sessionStart', 'beforeSubmitPrompt', 'afterFileEdit', 'stop'],
36
+ file: (env) => path.join(homeDir(env), '.cursor', 'hooks.json'),
37
+ },
38
+ codex: {
39
+ agent: 'codex',
40
+ events: ['SessionStart', 'UserPromptSubmit', 'PostToolUse', 'Stop', 'SessionEnd'],
41
+ file: (env) => path.join(env.CODEX_HOME || path.join(homeDir(env), '.codex'), 'hooks.json'),
42
+ },
43
+ };
44
+
45
+ function homeDir(env) {
46
+ return env.HOME || os.homedir();
47
+ }
48
+
49
+ function quote(p) {
50
+ return `"${p.replace(/(["\\$`])/g, '\\$1')}"`;
51
+ }
52
+
53
+ export function hookCommand(agent, { nodePath = process.execPath, script = HOOK_SCRIPT } = {}) {
54
+ return `${quote(nodePath)} ${quote(script)} --agent ${agent}`;
55
+ }
56
+
57
+ function isEmberHook(h) {
58
+ return h && typeof h.command === 'string' && h.command.includes(MARKER);
59
+ }
60
+
61
+ /** Remove every Ember hook from a settings object (mutates). Returns events touched. */
62
+ export function removeEmberHooks(settings) {
63
+ const touched = [];
64
+ const hooks = settings.hooks;
65
+ if (!hooks || typeof hooks !== 'object') return touched;
66
+ for (const event of Object.keys(hooks)) {
67
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
68
+ let changed = false;
69
+ const kept = [];
70
+ for (const group of groups) {
71
+ if (!group || !Array.isArray(group.hooks)) {
72
+ kept.push(group);
73
+ continue;
74
+ }
75
+ const rest = group.hooks.filter((h) => !isEmberHook(h));
76
+ if (rest.length !== group.hooks.length) changed = true;
77
+ if (rest.length) kept.push({ ...group, hooks: rest });
78
+ }
79
+ if (changed) {
80
+ touched.push(event);
81
+ if (kept.length) hooks[event] = kept;
82
+ else delete hooks[event];
83
+ }
84
+ }
85
+ if (Object.keys(hooks).length === 0) delete settings.hooks;
86
+ return touched;
87
+ }
88
+
89
+ /** Cursor format: { version: 1, hooks: { event: [{ command }] } }. Remove Ember entries (mutates). */
90
+ export function removeCursorHooks(settings) {
91
+ const touched = [];
92
+ const hooks = settings.hooks;
93
+ if (!hooks || typeof hooks !== 'object') return touched;
94
+ for (const event of Object.keys(hooks)) {
95
+ const list = Array.isArray(hooks[event]) ? hooks[event] : [];
96
+ const kept = list.filter((h) => !isEmberHook(h));
97
+ if (kept.length !== list.length) {
98
+ touched.push(event);
99
+ if (kept.length) hooks[event] = kept;
100
+ else delete hooks[event];
101
+ }
102
+ }
103
+ if (Object.keys(hooks).length === 0) delete settings.hooks;
104
+ return touched;
105
+ }
106
+
107
+ export function addCursorHooks(settings, events, command) {
108
+ if (typeof settings.version !== 'number') settings.version = 1;
109
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
110
+ for (const event of events) {
111
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
112
+ settings.hooks[event].push({ command, timeout: 10 });
113
+ }
114
+ }
115
+
116
+ /** Add Ember hooks for `events` (mutates). Call removeEmberHooks first for idempotency. */
117
+ export function addEmberHooks(settings, events, command) {
118
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
119
+ for (const event of events) {
120
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
121
+ // No matcher: applies to every tool for PostToolUse and is ignored elsewhere.
122
+ settings.hooks[event].push({ hooks: [{ type: 'command', command }] });
123
+ }
124
+ }
125
+
126
+ function timestamp() {
127
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.(\d+)Z$/, '$1Z');
128
+ }
129
+
130
+ function readSettings(file) {
131
+ if (!fs.existsSync(file)) return { exists: false, settings: {} };
132
+ const text = fs.readFileSync(file, 'utf8');
133
+ if (!text.trim()) return { exists: true, settings: {} };
134
+ const settings = JSON.parse(text); // throws on invalid JSON: never clobber a file we can't parse
135
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
136
+ throw new Error(`${file} does not contain a JSON object`);
137
+ }
138
+ return { exists: true, settings };
139
+ }
140
+
141
+ function backup(file) {
142
+ const dest = `${file}.bak-${timestamp()}`;
143
+ fs.copyFileSync(file, dest);
144
+ return dest;
145
+ }
146
+
147
+ function writeJson(file, obj, mode) {
148
+ fs.mkdirSync(path.dirname(file), { recursive: true });
149
+ const tmp = `${file}.${process.pid}.tmp`;
150
+ fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, mode ? { mode } : undefined);
151
+ if (mode) fs.chmodSync(tmp, mode);
152
+ fs.renameSync(tmp, file);
153
+ }
154
+
155
+ /** Write api_key / api_url into ~/.ember/config.json (0600), preserving other fields. */
156
+ export function writeEmberConfig({ key, apiUrl, env = process.env }) {
157
+ const home = env.EMBER_HOME || path.join(homeDir(env), '.ember');
158
+ const file = path.join(home, 'config.json');
159
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
160
+ let cfg = {};
161
+ try {
162
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
163
+ } catch {
164
+ /* new or unreadable config */
165
+ }
166
+ if (key) cfg.api_key = key;
167
+ if (apiUrl) cfg.api_url = apiUrl.replace(/\/+$/, '');
168
+ if (!cfg.api_url) cfg.api_url = 'https://ember-dev.vercel.app';
169
+ writeJson(file, cfg, 0o600);
170
+ return file;
171
+ }
172
+
173
+ /** Install or uninstall hooks for one target. Returns a summary; throws on unparseable target file. */
174
+ export function apply(targetName, { uninstall = false, env = process.env, nodePath, script } = {}) {
175
+ const target = TARGETS[targetName];
176
+ if (!target) throw new Error(`unknown target "${targetName}" (expected claude, codex or cursor)`);
177
+ const file = target.file(env);
178
+ const { exists, settings } = readSettings(file);
179
+ const before = JSON.stringify(settings);
180
+
181
+ const cursor = target.format === 'cursor';
182
+ const removed = cursor ? removeCursorHooks(settings) : removeEmberHooks(settings);
183
+ const command = hookCommand(target.agent, { nodePath, script });
184
+ if (!uninstall) (cursor ? addCursorHooks : addEmberHooks)(settings, target.events, command);
185
+
186
+ if (JSON.stringify(settings) === before) {
187
+ return { file, changed: false, backup: null, removed, added: [], command };
188
+ }
189
+ const backupFile = exists ? backup(file) : null;
190
+ writeJson(file, settings);
191
+ return { file, changed: true, backup: backupFile, removed, added: uninstall ? [] : target.events, command };
192
+ }
193
+
194
+ function parseCli(argv) {
195
+ const opts = { target: undefined, uninstall: false, key: undefined, apiUrl: undefined };
196
+ for (let i = 0; i < argv.length; i++) {
197
+ const a = argv[i];
198
+ if (a === '--uninstall') opts.uninstall = true;
199
+ else if (a === '--key') opts.key = argv[++i];
200
+ else if (a.startsWith('--key=')) opts.key = a.slice(6);
201
+ else if (a === '--api-url') opts.apiUrl = argv[++i];
202
+ else if (a.startsWith('--api-url=')) opts.apiUrl = a.slice(10);
203
+ else if (a === '-h' || a === '--help') opts.help = true;
204
+ else if (!a.startsWith('-') && !opts.target) opts.target = a;
205
+ else throw new Error(`unexpected argument: ${a}`);
206
+ }
207
+ return opts;
208
+ }
209
+
210
+ const USAGE = `Usage: node install.mjs <claude|codex|cursor> [--uninstall] [--key <ember_key>] [--api-url <url>]
211
+ node install.mjs --key <ember_key> [--api-url <url>]`;
212
+
213
+ function main() {
214
+ let opts;
215
+ try {
216
+ opts = parseCli(process.argv.slice(2));
217
+ } catch (e) {
218
+ console.error(`${e.message}\n${USAGE}`);
219
+ return 1;
220
+ }
221
+ if (opts.help || (!opts.target && !opts.key && !opts.apiUrl)) {
222
+ console.log(USAGE);
223
+ return opts.help ? 0 : 1;
224
+ }
225
+ if (opts.key !== undefined && !KEY_RE.test(opts.key)) {
226
+ console.error('Invalid key: expected "ember_" followed by 40 hex characters.');
227
+ return 1;
228
+ }
229
+
230
+ if (opts.key || opts.apiUrl) {
231
+ const file = writeEmberConfig({ key: opts.key, apiUrl: opts.apiUrl });
232
+ console.log(`Wrote ${file} (mode 0600)`);
233
+ }
234
+ if (!opts.target) return 0;
235
+
236
+ let result;
237
+ try {
238
+ result = apply(opts.target, { uninstall: opts.uninstall });
239
+ } catch (e) {
240
+ console.error(`Error: ${e.message}`);
241
+ return 1;
242
+ }
243
+
244
+ if (!result.changed) {
245
+ console.log(`No changes needed in ${result.file}`);
246
+ } else {
247
+ if (result.backup) console.log(`Backed up ${result.file} -> ${result.backup}`);
248
+ if (result.removed.length) console.log(`Removed Ember hooks from: ${result.removed.join(', ')}`);
249
+ if (result.added.length) {
250
+ console.log(`Added Ember hooks for: ${result.added.join(', ')}`);
251
+ console.log(` command: ${result.command}`);
252
+ }
253
+ console.log(`Updated ${result.file}`);
254
+ }
255
+ if (opts.target === 'codex' && !opts.uninstall) {
256
+ console.log(
257
+ '\nCodex: hooks that are not managed by your organisation must be reviewed and trusted\n' +
258
+ 'before they run. Start Codex and open /hooks to review and trust the Ember hooks.',
259
+ );
260
+ }
261
+ if (!opts.uninstall && !opts.key) {
262
+ const cfg = path.join(process.env.EMBER_HOME || path.join(homeDir(process.env), '.ember'), 'config.json');
263
+ if (!fs.existsSync(cfg)) console.log(`\nNo API key yet: run again with --key <ember_...> or set it in VS Code.`);
264
+ }
265
+ return 0;
266
+ }
267
+
268
+ function isMain() {
269
+ try {
270
+ return fs.realpathSync(process.argv[1] || '') === fs.realpathSync(fileURLToPath(import.meta.url));
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ if (isMain()) process.exitCode = main();
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "embertime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Connect VS Code, Cursor, Windsurf, Claude Code and Codex to Ember in one command. Tracks coding time, never code or prompts.",
5
+ "type": "module",
6
+ "bin": {
7
+ "embertime": "cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "ember-hook.mjs",
12
+ "install.mjs",
13
+ "hooks",
14
+ ".claude-plugin",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "license": "MIT",
22
+ "keywords": [
23
+ "time-tracking",
24
+ "wakatime",
25
+ "claude-code",
26
+ "codex",
27
+ "cursor",
28
+ "vscode",
29
+ "heatmap"
30
+ ],
31
+ "scripts": {
32
+ "test": "node --test test/*.test.mjs"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/Deepak-negi11/ember.git",
37
+ "directory": "collectors/agent-hook"
38
+ },
39
+ "homepage": "https://ember-dev.vercel.app",
40
+ "bugs": {
41
+ "url": "https://github.com/Deepak-negi11/ember/issues"
42
+ }
43
+ }